Streaming markdown renderer for Phoenix LiveView.
Renders LLM-streamed markdown with proper handling of incomplete syntax, block-level memoization, and minimal DOM updates.
Usage
use PhoenixStreamdown
<.markdown content={@response} streaming={@streaming?} />Or without importing:
<PhoenixStreamdown.markdown content={@response} streaming={@streaming?} />How it works
PhoenixStreamdown.Remend— auto-closes incomplete markdown syntax (**bold→**bold**, unclosed code fences, partial links)PhoenixStreamdown.Blocks— splits markdown into independent blocks- MDEx — renders each block to HTML server-side (Rust-backed)
- LiveView — diffs only the active block, skips the rest
Completed blocks get phx-update="ignore" so LiveView skips them entirely.
On a 56-block document, this means ~7x less server CPU and ~460x smaller
diffs per token compared to re-rendering the full document each time.
Animations
Enable word-level streaming animations (like Vercel's Streamdown):
<.markdown content={@response} streaming animate="fadeIn" />Available animations: fadeIn (default), blurIn, slideUp.
Include the CSS in your app:
@import "../../deps/phoenix_streamdown/priv/static/phoenix_streamdown.css";Customization
Syntax highlighting theme
<.markdown content={@md} theme="catppuccin_mocha" />Available themes: onedark (default), dracula, github_dark, github_light,
catppuccin_mocha, nord, tokyonight_night, vscode_dark, and
100+ more.
CSS classes
<.markdown content={@md} class="prose" block_class="mb-4" />Stable IDs
Each component auto-generates a unique id. For completed messages in a list,
pass a stable ID to preserve frozen blocks across re-renders:
<.markdown content={msg.content} id={"msg-#{msg.id}"} />The streaming component doesn't need an explicit id — it's ephemeral.
MDEx options
Options are deep-merged with defaults, so you only override what you need:
<.markdown content={@md} mdex_opts={[extension: [shortcodes: true]]} />Why not just MDEx streaming?
MDEx has its own streaming: true mode. PhoenixStreamdown adds:
- Block-level memoization — only the last block re-renders per token
- Ready-made LiveView component — drop in, pass content and streaming flag
- Remend — strips partial links/images instead of rendering broken HTML
- Word-level animations — fade-in/blur-in each new word as it streams
Summary
Functions
Imports the markdown/1 component for use as <.markdown />.
Renders markdown content with streaming support.
Functions
Imports the markdown/1 component for use as <.markdown />.
defmodule MyAppWeb.ChatLive do
use MyAppWeb, :live_view
use PhoenixStreamdown
def render(assigns) do
~H"""
<.markdown content={@response} streaming={@streaming?} />
"""
end
end
Renders markdown content with streaming support.
Attributes
content— the markdown string to renderstreaming— whether content is still being streamed (enables incomplete syntax completion)animate— animation type for streaming:"fadeIn","blurIn","slideUp", ornilto disableclass— CSS class for the wrapper<div>block_class— CSS class for each block<div>id— unique ID prefix (auto-generated; pass explicitly for stable IDs across re-renders)theme— syntax highlighting theme name (default:"onedark")mdex_opts— options passed toMDEx.to_html!/2(merged with defaults)
Examples
<PhoenixStreamdown.markdown content="# Hello **world**" />
<PhoenixStreamdown.markdown
content={@response}
streaming
animate="blurIn"
class="prose"
theme="github_dark"
/>Attributes
content(:string) - Defaults to"".streaming(:boolean) - Defaults tofalse.animate(:string) - Defaults tonil.class(:any) - Defaults tonil.block_class(:any) - Defaults tonil.id(:string)theme(:string) - Defaults to"onedark".mdex_opts(:list) - Defaults to[].