Markdown that draws while the model is still writing

flowdown renders an AI reply in React as it streams: GitHub Markdown plus steps, tabs, figures, diagrams and math. Its own parser, no dependencies, 32 KB.

npm install @localix-ai/flowdown

React 18 and 19 · MIT · Docs · also outside React

Streaming a reply

  1. Install
    One package, no dependencies.
    sh
    npm install @localix-ai/flowdown
    
  2. Render
    Pass streaming while the text is still arriving.
    reply.tsxtsx
    import { Markdown } from '@localix-ai/flowdown'
    import '@localix-ai/flowdown/styles.css'
    
    export function Reply({ text, done }) {
      return <Markdown streaming={!done}>{text}</Markdown>
    }
    
  3. Teach the model
    Add answerFormatPrompt() to the system prompt, and it writes these forms.
  • 0.14 sto stream 100k characters
  • 32 KBgzipped
  • 0dependencies

The same reply, outside React:

ts
import { renderToHtml } from '@localix-ai/flowdown/static'

const html = renderToHtml(reply)

Streaming 100k charactersTime
flowdown0.14 s
markdown-to-jsx2.4 s
Streamdown6.5 s

Read more

1,736 characters

Built for a reply, not a document

Six things it does

Streaming

Nothing half-written reaches the reader

A reply arrives mid-word. Unfinished **bold, a half-typed link and an open code fence are repaired as they come, so raw syntax never shows and nothing flickers when it closes.

arrivesis drawn asThe **answer isThe answer is  (bold)see [the guisee the gui```ts⏎const a =a code block, still open| a | b |⏎| -a table, its first row$$ \frac{1held until it can be setonly the block being written is parsed again

Dialect

Forms a model can write and a reader can scan

Steps, tabs, headline figures, the case for and against, callouts, folded sections, sources and file trees, on top of GitHub Markdown. Each is a quote line and a list: where it is not rendered, it still reads.

Rolling back a deploy

  1. Find the last good release
    git log --oneline --first-parent main
  2. Deploy it
    bun run deploy --ref v1.4.2
  3. Check the health page
    It turns green within a minute.
  • 40 sto roll back
  • 0requests dropped
deploy
releases
v1.4.2the last good one
v1.5.0the bad one
current -> releases/v1.4.2
Why not revert the commit?

A revert builds and ships a new release, which takes minutes. A rollback points current at a release that is already built.

Simplicity is prerequisite for reliability. — Edsger W. Dijkstra

  • rolled back
  • write the incident note

Read more

902 characters

Speed

A long reply costs what a short one does

A 100k-character reply streams in 0.14 s of main-thread time; the next fastest of six React renderers takes 2.4 s. No update takes more than 5 ms, so a stream never drops a frame.

streaming, mschat 5kcode 50kanswer 100kflowdown1249138markdown-to-jsx15632,393markstream411,0765,714Streamdown515,9266,529marked-react37807,709react-markdown2133,808stopped32 KB gzipped, one package, no dependencies

Targets

The same reply outside React

As HTML text on a server, in a page with no framework, as a <flow-down> element, or as the rows of a terminal: this one is @localix-ai/flowdown/tui, live. One parser draws them all, and none of these entries imports React.

Move the API to Bun
▌ Warning
▌ Back up package-lock.json first.
1 Install
│ Run bun install.
│
2 Swap the scripts
Replace node with bun in package.json.
src/server.ts
import { serve } from 'bun'
serve({ port: 3000, fetch: () => new Response('ok') })
- "start": "node dist/server.js"
+ "start": "bun src/server.ts"
│ Check │ Before │ After │
├────────────┼────────┼───────┤
│ Cold start │ 410 ms │ 95 ms │
│ p95 │ 38 ms │ 21 ms │
✓ lockfile backed up
☐ deploy to staging
Throughput is r = n / t, and over k samples
r̄ = 1/k ∑ᵢ₌₁ᵏ rᵢ
Further reading
[1] Bun runtime — what differs from Node
https://bun.sh/docs/runtime
765 characters

Diagrams and math

A diagram a reader can open

A mermaid fence is drawn once it is whole, in the page’s colours, and opens in a viewer that zooms and pans: try it. Formulas are set by KaTeX. Both load only for a reply that has one.

Between your message and the reply

The page draws while the model writes:

sequenceDiagram
  participant You
  participant App
  participant Model
  You->>App: Send a message
  App->>Model: The conversation
  loop Every token
    Model-->>App: The text so far
    App-->>You: Draw the last block again
  end
  Model-->>App: Done
Drawing diagram…

Hover the diagram for its toolbar: Enlarge opens a viewer that zooms and pans.

With $n$ characters arriving at $r$ a second, the reply takes

$$ t = \frac{n}{r} $$

and each update costs the last block, not the whole text.

577 characters

Safety

A reply cannot act on the page

A model’s text is untrusted. Raw HTML is parsed and sanitized, scripts and handlers are dropped, and a link keeps only a safe protocol. It needs no setup and no second library.

the reply<img src=x onerror="steal()">[click](javascript:alert(1))<script>fetch('/token')</script>the page<img src="x"/><p><a>click</a></p>(nothing)

Documentation

19 pages →

Releases

Full changelog →
  1. v0.5.0

    <Markdown> draws what it drew. It now loads 31.7 KB gzipped, from 30.5: the renderer is shared by every target.

    3 changes

  2. v0.4.3

    A shorter README and package description

    1 change

  3. v0.4.2

    Two replies that stream at once no longer slow each other down. The scan for a fence left open remembered one text for the whole page, so each reply made the other's start over on every token: two 100k-character replies side by side cost twice what they cost one after the other. Each <Markdown> now has a scan of its own.

    1 change

  4. v0.4.1

    Each icon is one <path>. Its strokes are subpaths of a single outline, and its circle or rounded square is written out as arcs, so a callout, a code block's buttons and a diagram's toolbar put fewer elements on the page: a 20k-character reply draws 452 where it drew 498. The icons look the same. A stylesheet that addressed an icon's <circle>, <rect> or one of its strokes by position no longer matches; svg and svg path still do.

    1 change

  5. v0.4.0

    Code blocks have a download button beside the copy button: it saves the code under the fence's title, or as code.<ext> from its language (code.txt when there is none). New label: download. The copy button falls back to the browser's copy command where the Clipboard API is missing or refused (an insecure origin), so it shows everywhere, a server render included. On a touch screen both buttons are 44 px targets. A fence with no language has no empty language label.

    4 changes

Start in three steps

Free · MIT license

  1. Install

    npm install @localix-ai/flowdown. React is its only peer, and only for <Markdown>.

  2. Render

    Import @localix-ai/flowdown/styles.css once, then <Markdown streaming={!done}>{text}</Markdown>.

  3. Teach the model

    Add answerFormatPrompt() from @localix-ai/flowdown/prompt to the system prompt.