npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

pretext-flow

v0.2.0

Published

Embed anything in text. Built on @chenglou/pretext.

Readme

pretext-flow

Embed anything in text. Shapes. Animations. Interactions. No geometry code.

The missing abstraction layer for @chenglou/pretext.

MIT License npm version Built on Pretext TypeScript

↑ Click to open the live demo

Live Demo · npm · GitHub



Why this exists

Pretext changed how text works on the web. It measures and positions text without touching the DOM, hundreds of times faster than traditional methods. But Pretext is a measurement engine. It tells you where characters go. It doesn't know about the things you want to put between them.

Every Pretext demo that embeds objects in text — the Bun mascot, the Situational Awareness layout, the editorial engine — writes its own collision code from scratch. Circle intersection math. Interval carving. Line-by-line obstacle routing. 200 to 400 lines, every time, for every demo.

pretext-flow is that code, written once, tested, and published.


What it does

┌──────────────────────────────────┐
│ Text text text text text text    │
│ text text text ┌──────┐ text     │
│ text text text │ logo │ text     │
│ text text text └──────┘ text     │
│ text text text text text text    │
└──────────────────────────────────┘

You give pretext-flow your text, your font, your container width, and an array of shapes to embed. It gives you back an array of positioned text lines with exact x, y, width values, and resolved bounding rects for each embed. You render the result however you want: Canvas, SVG, DOM, WebGL.

One function call. No geometry code. No collision math.

import { flowLayout } from 'pretext-flow'

const result = flowLayout({
  text: article,
  font: '18px Georgia',
  width: 600,
  lineHeight: 26,
  embeds: [
    {
      id: 'logo',
      shape: { type: 'circle', radius: 50 },
      position: { type: 'flow', paragraph: 1, progress: 0.4, side: 'right' },
      margin: 16,
    },
  ],
})

// result.lines  → positioned text lines
// result.embeds → resolved embed rects
// result.height → total content height

The bigger picture

Print magazines have done this for a hundred years. Text flows around photographs, illustrations, pull quotes, and advertisements. The reader's eye moves naturally. It feels designed, not interrupted.

The web never had this. CSS float is static. CSS shape-outside requires alpha masks and doesn't respond to interaction. Every "text wrapping around an image" solution on the web is a hack built on layout primitives never designed for it.

Pretext removed the performance barrier. pretext-flow removes the complexity barrier. Together they make magazine-style text flow a first-class web capability — interactive and real-time, at 60fps.

This opens doors that didn't exist before:

  • Editorial layouts where text wraps organically around photographs, charts, and illustrations
  • Branded content where a logo or product lives inside the reading flow, not beside it
  • In-text interactive widgets that readers engage with while reading, not in a separate block
  • Accessible advertising that exists within the content experience rather than interrupting it
  • Creative typography where text flows around animated shapes, user-controlled objects, or generative art

Install

npm install pretext-flow @chenglou/pretext

@chenglou/pretext is a peer dependency. You bring your own version.


Shapes

Four shape types for text to flow around:

{ type: 'circle', radius: 50 }

{ type: 'rect', width: 200, height: 120 }

{ type: 'ellipse', radiusX: 80, radiusY: 40 }

{
  type: 'polygon',
  width: 200,
  height: 200,
  points: [
    { x: 0.5, y: 0 },
    { x: 1,   y: 0.5 },
    { x: 0.5, y: 1 },
    { x: 0,   y: 0.5 },
  ],
}

Positioning

Absolute — exact pixel coordinates. Good for draggable or fixed layouts.

position: { type: 'absolute', x: 300, y: 100 }

Flow — relative to a paragraph's vertical extent. Good for editorial layouts where content length varies.

position: {
  type: 'flow',
  paragraph: 0,    // which paragraph (split by \n)
  progress: 0.4,   // 40% down the paragraph
  side: 'right',   // 'left' | 'right' | 'center'
}

Rendering

pretext-flow is renderer-agnostic. It returns data. You draw it.

Canvas:

const { lines, embeds } = flowLayout(config)

for (const line of lines) {
  ctx.fillText(line.text, line.x, line.y + lineHeight)
}
for (const embed of embeds) {
  ctx.drawImage(img, embed.rect.x, embed.rect.y, embed.rect.width, embed.rect.height)
}

DOM:

for (const line of lines) {
  const span = document.createElement('span')
  span.textContent = line.text
  span.style.cssText = `position:absolute; left:${line.x}px; top:${line.y}px`
  container.appendChild(span)
}

React:

function FlowText({ config }) {
  const canvasRef = useRef(null)
  useEffect(() => {
    const { lines } = flowLayout(config)
    const ctx = canvasRef.current.getContext('2d')
    ctx.font = config.font
    for (const line of lines) ctx.fillText(line.text, line.x, line.y + config.lineHeight)
  }, [config])
  return <canvas ref={canvasRef} />
}

Animation

New in v0.2

Embeds follow paths, hold keyframes, and loop — all at 60fps without touching the DOM.

import { createAnimationController } from 'pretext-flow'

const controller = createAnimationController({
  text: article,
  font: '18px Georgia',
  width: 600,
  lineHeight: 26,
  embeds: [
    {
      id: 'orbit',
      shape: { type: 'circle', radius: 24 },
      position: { type: 'flow', paragraph: 0, progress: 0.5, side: 'right' },
      path: {
        segments: [
          {
            type: 'arc',
            center: { x: 300, y: 120 },
            radius: 80,
            startAngle: 0,
            endAngle: Math.PI * 2,
          },
        ],
        duration: 4000,
        loop: 'loop',
      },
    },
  ],
})

// In your rAF loop:
function frame(now) {
  const result = controller.tick(now)
  render(result)
  requestAnimationFrame(frame)
}
requestAnimationFrame(frame)

controller.paused = true  // pause / resume
controller.reset()        // restart from t=0

Effects

New in v0.2

Per-character text effects on every tick. Requires characterPositions: true.

import { createAnimationController, ambientDrift, cursorRipple } from 'pretext-flow'

const controller = createAnimationController({
  text: article,
  font: '18px Georgia',
  width: 600,
  lineHeight: 26,
  characterPositions: true,
  effects: [
    ambientDrift({ amplitude: 2, speed: 0.6 }),
    cursorRipple({ radius: 80, strength: 6 }),
  ],
  embeds: [],
})

canvas.addEventListener('mousemove', e => {
  controller.setCursor({ x: e.offsetX, y: e.offsetY })
})

| Effect | Description | | --- | --- | | ambientDrift(options) | Slow sinusoidal float — characters drift independently | | cursorRipple(options) | Characters near the cursor are pushed away | | wave(options) | Horizontal wave rolling through the text | | inkDensity(options) | Character opacity pulses in a spreading ink pattern |


Hit Testing

New in v0.2

import { hitTest, hitTestCharacter } from 'pretext-flow'

canvas.addEventListener('click', e => {
  const pt = { x: e.offsetX, y: e.offsetY }

  const embed = hitTest(result.embeds, pt)
  if (embed) console.log('clicked embed:', embed.id)

  const char = hitTestCharacter(result.lines, pt)
  if (char) console.log('clicked char:', char.char, 'at line', char.lineIndex)
})

Events

New in v0.2

High-level pointer events on embeds — hover, click, drag.

import { createEmbedEventDispatcher } from 'pretext-flow'

const dispatch = createEmbedEventDispatcher(result.embeds, {
  onClick:    (embed, pt)          => console.log('clicked', embed.id),
  onHover:    (embed, pt)          => setCursor('pointer'),
  onHoverEnd: ()                   => setCursor('default'),
  onDragEnd:  (embed, newPosition) => controller.moveEmbed(embed.id, newPosition),
})

canvas.addEventListener('pointermove', e => dispatch.move({ x: e.offsetX, y: e.offsetY }))
canvas.addEventListener('pointerdown', e => dispatch.down({ x: e.offsetX, y: e.offsetY }))
canvas.addEventListener('pointerup',   e => dispatch.up({ x: e.offsetX, y: e.offsetY }))

Templates

New in v0.2

Pre-built layout presets for common editorial patterns.

import { mergeTemplate, TEMPLATES } from 'pretext-flow'

const config = mergeTemplate(TEMPLATES.pullQuote, { text: article, width: 680 })
const result = flowLayout(config)

Hull Extraction

New in v0.2

Extract a polygon outline from an image's alpha channel for precise wrapping around irregular shapes.

import { hullFromImage } from 'pretext-flow'

const hull = await hullFromImage(imageElement, { threshold: 0.1, simplify: 4 })

flowLayout({
  text: article,
  font: '18px Georgia',
  width: 600,
  lineHeight: 26,
  embeds: [{
    id: 'figure',
    shape: hull,
    position: { type: 'absolute', x: 320, y: 40 },
    margin: 12,
  }],
})

API Reference

flowLayout(config: FlowConfig): FlowResult

| Config field | Type | Default | Description | | -------------------- | --------- | ------- | -------------------------------------------------------- | | text | string | | Text content. \n = paragraph break | | font | string | | CSS font shorthand | | width | number | | Column width in px | | lineHeight | number | | Line height in px | | embeds | Embed[] | [] | Objects to embed in the flow | | minSlotWidth | number | 24 | Discard text slots narrower than this | | paragraphGap | number | 0 | Extra spacing between paragraphs (px) | | characterPositions | boolean | false | Compute per-character positions (required for effects) |

FlowResult

| Field | Type | Description | | -------- | ----------------- | ----------------------------- | | lines | FlowLine[] | Positioned text lines | | embeds | ResolvedEmbed[] | Resolved embed bounding rects | | height | number | Total content height |

createAnimationController(config): AnimationController

| Method / Property | Description | | ------------------------------ | ------------------------------------------------------- | | tick(time: number): FlowResult | Advance to time ms and return the current layout | | paused: boolean | Get or set pause state | | elapsed: number | Current animation time in ms | | reset() | Restart from t=0 | | updateConfig(partial) | Hot-swap text, font, width, or embeds | | moveEmbed(id, position) | Override an embed's position at runtime | | setCursor(point \| null) | Update cursor position for cursor-aware effects |

Low-level utilities

import { shapeIntervalForBand, carveSlots } from 'pretext-flow'

const interval = shapeIntervalForBand(shape, rect, bandTop, bandBottom, margin)
const slots    = carveSlots(baseInterval, blockedIntervals, minWidth)

How it works

  1. Split text into paragraphs at \n boundaries
  2. Prepare each paragraph with Pretext's prepareWithSegments()
  3. First pass: compute each paragraph's vertical extent (without embeds) to resolve flow positions
  4. Resolve each embed's position to an absolute bounding rect
  5. Second pass: for each text line band — compute overlapping embeds, carve available text slots, call Pretext's layoutNextLine() with the widest slot
  6. Return all positioned lines and resolved embed rects

All text measurement goes through Pretext. Zero DOM reads. Zero reflows.


Performance

Tested in-browser against real Pretext, no mocks:

| Scenario | Result | | -------------------------------- | ----------------------- | | 10,000 words + 3 embeds | ~1,200 lines in ~17ms | | 10 simultaneous embeds | ~90 lines in ~2ms | | 60-frame drag simulation | avg 0.4ms, max 1.0ms | | Headroom under 16ms frame budget | 40× |

167 unit tests + browser stress tests. Run them live.


Development

Tests use Bun:

npm install         # install dependencies
bun test            # run 167 tests (requires Bun)
npx tsc --noEmit    # type-check
npm run build       # compile to dist/
npm run preview     # local HTTP server for the demo

Acknowledgments

  • Cheng Lou for Pretext — the foundation this is built on.
  • The wrap-geometry.ts patterns in Pretext's demo source informed the collision math.

License

MIT