Can you explain how pages render?
Message
One chat message with avatar, author, time, streaming Markdown, attachments, actions and response branches.
Conversation
A user bubble at the end, an assistant reply at the start with avatar, time and actions.
Every page renders on the server first, then attaches in the browser, so the first response is the finished HTML.
Code
<Message from="user" author="Ada" avatar={ada} time="2026-10-29T14:02:00Z" timeLabel="2:02 PM">
<MessageContent>Can you explain how pages render?</MessageContent>
</Message>
<Message from="assistant" author="Claude" showAvatar time="2026-10-29T14:02:05Z" timeLabel="2:02 PM">
<MessageContent>Every page renders on the server first, then attaches in the browser.</MessageContent>
<MessageActions>
<MessageAction label="Retry" tooltip="Regenerate" onclick={() => retry()}><Icon name="refresh-cw" /></MessageAction>
<MessageAction label="Copy" tooltip="Copy" onclick={() => copy()}><Icon name="copy" /></MessageAction>
<MessageAction label="Good response" toggle pressed={state.liked}><Icon name="thumbs-up" /></MessageAction>
<MessageAction label="Bad response" toggle pressed={state.disliked}><Icon name="thumbs-down" /></MessageAction>
</MessageActions>
</Message>Usage
import { Message, MessageContent, MessageResponse, MessageActions, MessageAction } from "@elements/ui/message";
<Message from="user" author="Ada" time={m.createdAt}>
<MessageContent>{m.text}</MessageContent>
</Message>
<Message from="assistant" author="Claude" showAvatar>
<MessageContent>
<MessageResponse markdown={reply.text} streaming={reply.streaming} />
</MessageContent>
<MessageActions>
<MessageAction label="Retry" tooltip="Regenerate" onclick={() => retry(reply)}><Icon name="refresh-cw" /></MessageAction>
<MessageAction label="Copy" onclick={() => copy(reply.text)}><Icon name="copy" /></MessageAction>
</MessageActions>
</Message>Examples
Markdown response
Headings, emphasis, lists, task lists, code with copy, tables, quotes and links.
Setting up a page
Elements renders every page on the server first, then attaches in the browser. You need three things:
- A route in
app/routes.ts - A
template.ehtmlwith an<html>template - Optionally, a
style.css
- Route declared
- Template written
ts
export default function route(req: Request, res: Response) {
return new page({ title: "Hello" });
}| Command | What it does |
|---|---|
elements build | Builds and tests |
elements start | Runs the server |
Every save hot reloads the page.
Read more in the manual. No config needed.
Code
<Message from="assistant">
<MessageContent>
<MessageResponse markdown={reply.text} />
</MessageContent>
</Message>References and footnotes
Reference links and images, GFM footnotes with back links, www. autolinks, lazy quote lines and multi-backtick code spans.
Elements renders on the server first1, then hot reloads on save2. See the manual and www.elements.dev.
Quotes keep lazy lines: a line without its marker stays in the quote.
Code spans take longer fences: a ` b. Single and double tildes strike.
Code
<!-- reply.text ends with lines like [man]: https://elements.dev and [^1]: A note. -->
<MessageResponse markdown={reply.text} />Streaming
Unfinished syntax renders closed while text streams in; a link shows as a link before its url arrives; a caret follows the last block.
Here is a quick summary of the release:
- Faster builds
Code
<MessageResponse markdown={reply.text} streaming={reply.streaming} />
<!-- An unfinished link shows as plain text instead of a link with no url yet. -->
<MessageResponse markdown={reply.text} streaming={reply.streaming} linkMode="text-only" />Branches
Several versions of one reply. Previous and next wrap; the page says where you are.
What is the capital of France?
The capital of France is Paris.
Paris. It has been the capital since the 10th century, apart from a few short interruptions.
France's capital is Paris, on the Seine, home to about two million people.
1 of 3
branch: 0 · changes: 0
Only one version of this reply.
1 of 1
Code
<!-- count is optional: with it the server render already shows "1 of 3". -->
<MessageBranch branch={state.branch} count={versions.length} onBranchChange={(i) => log(i)}>
<MessageBranchContent>
<Message e:for={v of versions} e:key={(v) => v.id} from="assistant">
<MessageContent><MessageResponse markdown={v.text} /></MessageContent>
</Message>
</MessageBranchContent>
<MessageToolbar>
<MessageBranchSelector>
<MessageBranchPrevious />
<MessageBranchPage branch={state.branch} count={versions.length} />
<MessageBranchNext />
</MessageBranchSelector>
<MessageActions>...</MessageActions>
</MessageToolbar>
</MessageBranch>With attachments
Attachments above the bubble, in the grid variant.
brief.pdf
What does this chart show?
Code
<Message from="user">
<Attachments variant="grid">
<Attachment e:for={f of m.files} e:key={(f) => f.id} data={f} />
</Attachments>
<MessageContent>What does this chart show?</MessageContent>
</Message>System and grouped
System notices are centered; grouped follow-ups drop the header and keep the avatar column.
Ada joined the conversation
Morning! Standup in ten.
I will share the board.
Code
<Message from="system"><MessageContent>Ada joined the conversation</MessageContent></Message>
<Message from="user" author="Ada" avatar={ada} timeLabel="2:04 PM"><MessageContent>First</MessageContent></Message>
<Message from="user" author="Ada" avatar={ada} grouped><MessageContent>Second, grouped</MessageContent></Message>Variant and align
Override the defaults: a flat user message at the start, an assistant bubble.
Flat, at the start, like a chat room.
A bubble from the assistant.
Code
<Message from="user" variant="flat" align="start" author="Ada">...</Message>
<Message from="assistant" variant="bubble" author="Claude" showAvatar>...</Message>Loading
MessageLoading holds the place of a reply that has not started.
Code
<Message from="assistant" author="Claude" showAvatar>
<MessageContent><MessageLoading label="Claude is thinking" /></MessageContent>
</Message>Code, table and image controls
Code is highlighted with copy and download; tables copy as Markdown, CSV or TSV and download as CSV or Markdown; images download. All are disabled while streaming.
Highlighted, with copy and download:
python
def greet(name: str) -> str:
# Say hello
return f"Hello, {name}!" * 2| Region | Q3 | Q4 |
|---|---|---|
| North | 120 | 140 |
| South, east | 98 | 131 |
Code
<MessageResponse markdown={reply.text}
codeBlockMaxHeight={240}
onTableCopy={(text, format) => log(format)} />
<!-- Turn controls off: copyCode, downloadCode, tableControls, imageDownload, highlight -->Response options
Line numbers (noLineNumbers per block), a code theme (codeColors), raw languages with no highlighting or controls, download names, the CSV separator (auto follows the locale), urlTransform, autolink protocols, and which table controls show.
Numbered code, saved as snippet.ts:
ts
1
2
3let a = 1;
let b = 2;
let c = a + b;ts
let unnumbered = true;+---+---+
| a | b |
+---+---+| Item | Price |
|---|---|
| Tea; green | 3,50 |
| Cake | 4 |
from the report. Mail ada@elements.dev or see https://elements.dev.
| Item | Price |
|---|---|
| Tea | 3 |
| Cake | 4 |
| Only | Download |
|---|---|
| a | b |
| c | d |
| e | f |
| g | h |
| i | j |
| No | Controls |
|---|---|
| a | b |
Code
<MessageResponse markdown={text}
lineNumbers
codeColors={{ keyword: "var(--accent)", number: "var(--info-ink)", background: "var(--bg-muted)" }}
codeBlockRawLanguages={["ascii"]}
codeFilename="snippet"
tableFilename="report"
csvSeparator=";"
urlTransform={(url, key) => key === "src" ? proxy(url) : url}
disableAutolinkProtocols={["mailto"]}
onLinkCheck={async (url) => (await checkUrl(url)).isSafe}
onCodeCopyError={(err) => toast(err.message)} />
<!-- Only some table controls: -->
<MessageResponse markdown={text} tableCopy={false} tableFullscreen={false} tableMaxHeight={96} />
<MessageResponse markdown={text} tableControls={false} /> <!-- no table bar at all -->Link safety
A link asks before it opens, showing the full address with Copy link and Open link. onLinkCheck lets trusted links through.
Open the manual (safelisted, opens at once) or an outside page (asks first). Mail ada@elements.dev or ada@elements.dev.
With linkSafety off, this link opens directly.
Code
<!-- Every link asks first; onLinkCheck returns true to open at once. -->
<MessageResponse markdown={reply.text}
onLinkCheck={(url) => url.startsWith("https://elements.dev/")} />
<MessageResponse markdown={reply.text} linkSafety={false} />Direction and caret
dir=auto sets each block's direction from its own text; caret=circle swaps the block caret for a dot.
مرحبا، هذه فقرة باللغة العربية.
This paragraph is English, so it reads left to right.
Still writing this reply
Code
<MessageResponse markdown={reply.text} dir="auto" />
<MessageResponse markdown={reply.text} streaming caret="circle" />Safe links and images
Unsafe HTML stays text, javascript: is blocked, and prefixes limit links and images.
Code
<MessageResponse markdown={text}
allowedLinkPrefixes={["https://elements.dev"]}
allowedImagePrefixes={[]}
defaultOrigin="https://elements.dev" />HTML in replies
Safe tags render (kbd, sub, sup, br in a table cell, details); script, event handlers and javascript: urls never do. skipHtml leaves HTML out.
Press Ctrl + C. Water is H2O; area is r2.
| Step | Notes |
|---|---|
| Install | one two |
More detail
The Markdown inside renders too.
<script>alert(1)</script> blocked a raw link
Press Ctrl and x go.
Indented HTML
Code
<!-- Safe tags render; everything else stays text. -->
<MessageResponse markdown={reply.text} />
<MessageResponse markdown={reply.text} skipHtml />
<MessageResponse markdown={reply.text}
allowedTags={{ "cite-ref": ["id", "data-source"] }}
literalTagContent={["cite-ref"]}
normalizeHtmlIndentation />Element filters
allowedElements, disallowedElements and allowElement choose what renders; unwrapDisallowed keeps a left-out element's text.
Code
<MessageResponse markdown={reply.text} disallowedElements={["strong", "img"]} />
<MessageResponse markdown={reply.text} allowedElements={["p", "em"]} unwrapDisallowed />
<MessageResponse markdown={reply.text} allowElement={(el) => el.tagName !== "a"} unwrapDisallowed />Repair rules, labels and icons
Each stream repair rule can be turned off and handlers added; translations relabel every control and the link dialog; icons swap the control icons.
Unfinished **bold and code ...
One chunk for the whole reply.
Code
<MessageResponse markdown={reply.text}
repair={{ bold: false, handlers: [{ name: "ellipsis", handle: (t) => t + " ..." }] }} />
<MessageResponse markdown={reply.text} parseMarkdownIntoBlocksFn={(md) => [md]} />
<MessageResponse markdown={reply.text}
translations={{ copyCode: "Copiar código", copied: "Copiado" }}
icons={{ copy: "clipboard", download: "arrow-down" }} />Animated streaming
animated fades each new word in as it arrives (blur and slide-up are the other animations); words already shown stay still.
Code
<MessageResponse markdown={reply.text} streaming={reply.streaming} animated />
<MessageResponse markdown={reply.text} streaming={reply.streaming} animated animation="blur" animationSep="char" />