About
A writing canvas beside the chat: the document with its title and word count, suggested edits to accept or reject one at a time or all at once, and version history.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Canvas to your project and fits it to your theme.
Code
How Canvas is used: the demo's code. The component's own source comes with a free account.
Usage.tsx
"use client"
import * as React from "react"
import { Canvas, CanvasBlock, CanvasSelectionInfo, CanvasSuggestion, CanvasVersion, ShimmerText } from "@/components/ui/canvas"
const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"
/**
* 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
* phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
* render white on dark pages. FitFrame fills the frame exactly instead.
*
* - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
* - "crop": the content stays at full size from the top and fades out at the bottom. For text
* that streams in, where a changing scale would make the page breathe.
*
* `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
* to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
* shrinks evenly on both sides.
*
* Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
* shorter than `hideBelow`, so a profile card shows the visual alone.
*/
function FitFrame({
children,
mode = "scale",
hideBelow = 360,
minWidth,
}: {
children: React.ReactNode
mode?: "scale" | "crop"
hideBelow?: number
minWidth?: string
}) {
const content = React.useRef<HTMLDivElement>(null)
const [cropped, setCropped] = React.useState(false)
React.useLayoutEffect(() => {
const el = content.current
if (!el) return
const measure = () => {
const vw = window.innerWidth
const vh = window.innerHeight
el.toggleAttribute("data-fit-small", vh < hideBelow)
// Layout sizes ignore the transform, so measuring never feeds back.
const w = Math.max(el.offsetWidth, el.scrollWidth)
const h = Math.max(el.offsetHeight, el.scrollHeight)
const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
el.style.transform = scale < 1 ? `scale(${scale})` : ""
setCropped(mode === "crop" && h > vh + 1)
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(el)
// Content that changes inside a fixed width box (a headline that cycles words) never resizes
// the box itself, so changes below it measure again on the next frame.
let frame = 0
const mutations = new MutationObserver(() => {
cancelAnimationFrame(frame)
frame = requestAnimationFrame(measure)
})
mutations.observe(el, { subtree: true, childList: true, characterData: true })
window.addEventListener("resize", measure)
return () => {
observer.disconnect()
mutations.disconnect()
cancelAnimationFrame(frame)
window.removeEventListener("resize", measure)
}
}, [mode, hideBelow])
const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
return (
<div
className="flex w-full justify-center overflow-hidden"
style={{
height: "100dvh",
alignItems: mode === "crop" ? "flex-start" : "center",
maskImage: cropped ? fade : undefined,
WebkitMaskImage: cropped ? fade : undefined,
}}
>
<style>{FIT_CSS}</style>
<div
ref={content}
className="w-full shrink-0"
style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
>
{children}
</div>
</div>
)
}
interface UseChatcnCanvasSuggestionsOptions {
/** Label for the first entry in the version history. Defaults to "First draft". */
firstVersionLabel?: string
}
let seq = 0
const uid = (p: string) => `${p}-${Date.now().toString(36)}-${(++seq).toString(36)}`
/** Applies one suggestion to the blocks. */
function applyCanvasSuggestion(blocks: CanvasBlock[], s: CanvasSuggestion): CanvasBlock[] {
return blocks.map((b) =>
b.id === s.blockId
? { ...b, text: b.text.slice(0, s.from) + s.insert + b.text.slice(s.to) }
: b,
)
}
/** Moves the other suggestions in the same block after an accepted one changed its length. */
function shift(list: CanvasSuggestion[], accepted: CanvasSuggestion): CanvasSuggestion[] {
const delta = accepted.insert.length - (accepted.to - accepted.from)
const out: CanvasSuggestion[] = []
for (const s of list) {
if (s.id === accepted.id) continue
if (s.blockId !== accepted.blockId) out.push(s)
else if (s.from >= accepted.to) out.push({ ...s, from: s.from + delta, to: s.to + delta })
else if (s.to <= accepted.from) out.push(s)
// Overlapping edits no longer apply to the new text: drop them.
}
return out
}
/**
* Document state for `Canvas`: the blocks, suggested edits that can stream in, accept and
* reject (one or all), and a small version history that records each reviewed batch.
*/
function useChatcnCanvasSuggestions(
initialBlocks: CanvasBlock[],
{ firstVersionLabel = "First draft" }: UseChatcnCanvasSuggestionsOptions = {},
) {
const [blocks, setBlocks] = React.useState(initialBlocks)
const [suggestions, setSuggestions] = React.useState<CanvasSuggestion[]>([])
// The first version's time is stamped after mount: a render must not read the clock (Next.js
// prerender). Until then it carries the time of the first render on the client, set below.
const [versions, setVersions] = React.useState<CanvasVersion[]>(() => [
{ id: "v1", label: firstVersionLabel, createdAt: 0, blocks: initialBlocks },
])
React.useEffect(() => {
const stamped = Date.now()
setVersions((list) =>
list.map((v) => (v.id === "v1" && v.createdAt === 0 ? { ...v, createdAt: stamped } : v)),
)
}, [])
const [currentVersionId, setCurrentVersionId] = React.useState("v1")
const batch = React.useRef({ accepted: 0 })
const blocksRef = React.useRef(blocks)
blocksRef.current = blocks
const suggestionsRef = React.useRef(suggestions)
suggestionsRef.current = suggestions
const saveVersion = React.useCallback((label: string, snapshot = blocksRef.current) => {
setVersions((list) => {
const id = `v${list.length + 1}`
setCurrentVersionId(id)
return [...list, { id, label, createdAt: Date.now(), blocks: snapshot }]
})
}, [])
// A reviewed batch with at least one accepted edit becomes a version.
const settle = React.useCallback(
(nextSuggestions: CanvasSuggestion[], nextBlocks: CanvasBlock[]) => {
if (nextSuggestions.length > 0) return
const n = batch.current.accepted
batch.current.accepted = 0
if (n > 0) saveVersion(`Accepted ${n} ${n === 1 ? "edit" : "edits"}`, nextBlocks)
},
[saveVersion],
)
const suggest = React.useCallback((s: Omit<CanvasSuggestion, "id"> & { id?: string }) => {
const id = s.id ?? uid("s")
setSuggestions((list) => [...list, { status: "ready", ...s, id }])
return id
}, [])
/** Appends streamed text to a suggestion's insertion. */
const append = React.useCallback((id: string, chunk: string) => {
setSuggestions((list) =>
list.map((s) => (s.id === id ? { ...s, insert: s.insert + chunk } : s)),
)
}, [])
const finish = React.useCallback((id: string) => {
setSuggestions((list) => list.map((s) => (s.id === id ? { ...s, status: "ready" } : s)))
}, [])
const accept = React.useCallback(
(id: string) => {
const s = suggestionsRef.current.find((x) => x.id === id)
if (!s) return
const nextBlocks = applyCanvasSuggestion(blocksRef.current, s)
const nextSuggestions = shift(suggestionsRef.current, s)
batch.current.accepted += 1
setBlocks(nextBlocks)
setSuggestions(nextSuggestions)
settle(nextSuggestions, nextBlocks)
},
[settle],
)
const reject = React.useCallback(
(id: string) => {
const nextSuggestions = suggestionsRef.current.filter((x) => x.id !== id)
setSuggestions(nextSuggestions)
settle(nextSuggestions, blocksRef.current)
},
[settle],
)
const acceptAll = React.useCallback(() => {
let nextBlocks = blocksRef.current
let rest = suggestionsRef.current.filter((s) => s.status !== "streaming")
const streaming = suggestionsRef.current.filter((s) => s.status === "streaming")
// Apply from the end of each block backwards so earlier offsets stay valid.
const ordered = [...rest].sort((a, b) =>
a.blockId === b.blockId ? b.from - a.from : a.blockId < b.blockId ? -1 : 1,
)
for (const s of ordered) {
if (!rest.some((r) => r.id === s.id)) continue
nextBlocks = applyCanvasSuggestion(nextBlocks, s)
rest = rest.filter(
(r) => r.id !== s.id && !(r.blockId === s.blockId && r.to > s.from && r.from < s.to),
)
batch.current.accepted += 1
}
setBlocks(nextBlocks)
setSuggestions(streaming)
settle(streaming, nextBlocks)
}, [settle])
const rejectAll = React.useCallback(() => {
const streaming = suggestionsRef.current.filter((s) => s.status === "streaming")
setSuggestions(streaming)
settle(streaming, blocksRef.current)
}, [settle])
const restoreVersion = React.useCallback(
(id: string) => {
const v = versions.find((x) => x.id === id)
if (!v) return
setBlocks(v.blocks)
setSuggestions([])
batch.current.accepted = 0
setCurrentVersionId(id)
},
[versions],
)
return {
blocks,
setBlocks,
suggestions,
setSuggestions,
suggest,
append,
finish,
accept,
reject,
acceptAll,
rejectAll,
versions,
currentVersionId,
restoreVersion,
saveVersion,
}
}
const DRAFT: CanvasBlock[] = [
{ id: "title", type: "title", text: "Reading width for long answers" },
{
id: "intro",
type: "paragraph",
text: "We want long answers in the assistant to be really easy to read. Right now the message column basically stretches to the full width of the window, which means that lines can run to 140 characters on a large monitor.",
},
{ id: "h-research", type: "heading", text: "What the research says" },
{
id: "research",
type: "paragraph",
text: "Typographers have recommended somewhere between 45 and 75 characters per line for a very long time. Studies of reading on screens found that longer lines can be read a little faster, but in order to understand the text, readers do not do any better, and they say they prefer shorter lines.",
},
{ id: "h-proposal", type: "heading", text: "Proposal" },
{
id: "proposal",
type: "paragraph",
text: "Cap the message column at 68ch, which is approximately 70 characters in Geist at 16 px. Tables and code blocks are able to extend to the full width of the panel so that wide content does not wrap.",
},
{
id: "scope",
type: "paragraph",
text: "We will utilize the same cap in the canvas and in research reports, so that all long-form text in the product reads the same way.",
},
]
interface Edit {
blockId: string
find: string
insert: string
reason?: string
/** Offset of `find` in the block, when it appears more than once. */
at?: number
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
/** The agent's first pass over the draft. */
const FIRST_PASS: Edit[] = [
{ blockId: "intro", find: "really ", insert: "", reason: "Cut filler" },
{ blockId: "intro", find: "basically ", insert: "", reason: "Cut filler" },
{
blockId: "research",
find: "in order to understand the text, readers do not do any better",
insert: "comprehension does not improve",
reason: "Shorter, same meaning",
},
{ blockId: "proposal", find: "are able to", insert: "can", reason: "Plainer verb" },
{ blockId: "scope", find: "utilize", insert: "use", reason: "Plainer verb" },
]
/** What Rewrite knows how to tighten when you select a passage yourself. */
const RULES: [RegExp, string, string][] = [
[/\breally /g, "", "Cut filler"],
[/\bbasically /g, "", "Cut filler"],
[/\bwhich means that\b/g, "so", "Shorter"],
[/\bsomewhere between\b/g, "between", "Cut filler"],
[/\bfor a very long time\b/g, "for decades", "More specific"],
[/\ba little faster\b/g, "slightly faster", "Shorter"],
[/\bin order to\b/g, "to", "Shorter"],
[/\bapproximately\b/g, "about", "Plainer word"],
[/\bare able to\b/g, "can", "Plainer verb"],
[/\butilize\b/g, "use", "Plainer verb"],
[/\bso that\b/g, "so", "Shorter"],
]
const EXPLAIN: Record<string, string> = {
intro:
"This sets up the problem: on wide screens the column has no cap, so lines get far longer than people read comfortably.",
research:
"Long lines can be read slightly faster, but people understand them no better and prefer shorter ones.",
proposal:
"68ch is a width in characters of the zero glyph, so the cap scales with the font instead of the window.",
scope: "One measure everywhere means long text feels the same in chat, the canvas and reports.",
}
function CanvasDemo() {
const doc = useChatcnCanvasSuggestions(DRAFT)
const [notice, setNotice] = React.useState<React.ReactNode>(null)
const run = React.useRef(0)
const blocksRef = React.useRef(doc.blocks)
blocksRef.current = doc.blocks
const suggestionsRef = React.useRef(doc.suggestions)
suggestionsRef.current = doc.suggestions
/** Streams edits one after another, a few characters at a time. */
const stream = React.useCallback(
async (edits: Edit[], label: string, done: string) => {
const token = ++run.current
setNotice(<ShimmerText className="text-[0.8125rem]/[1.25rem]">{label}</ShimmerText>)
await sleep(700)
for (const edit of edits) {
if (run.current !== token) return
const block = blocksRef.current.find((b) => b.id === edit.blockId)
const from = edit.at ?? block?.text.indexOf(edit.find) ?? -1
if (!block || from < 0) continue
const taken = suggestionsRef.current.some(
(s) => s.blockId === edit.blockId && s.from < from + edit.find.length && s.to > from,
)
if (taken) continue
// One shimmer at a time: the review bar takes over the live status once edits arrive,
// so the notice settles to plain text.
if (!suggestionsRef.current.some((s) => s.status === "streaming"))
setNotice(<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted)">{label}</span>)
const id = doc.suggest({
blockId: edit.blockId,
from,
to: from + edit.find.length,
insert: "",
status: "streaming",
reason: edit.reason,
})
for (let i = 0; i < edit.insert.length; i += 3) {
await sleep(28)
if (run.current !== token) return
doc.append(id, edit.insert.slice(i, i + 3))
}
await sleep(160)
doc.finish(id)
await sleep(220)
}
if (run.current === token)
setNotice(<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted)">{done}</span>)
},
[doc.suggest, doc.append, doc.finish],
)
React.useEffect(() => {
const id = window.setTimeout(
() =>
void stream(
FIRST_PASS,
"Tightening the draft",
"Review the edits, then select any passage to ask about it.",
),
900,
)
return () => {
window.clearTimeout(id)
run.current++
}
}, [stream])
// Once every edit is reviewed, point at the next thing to try.
const pending = doc.suggestions.length
const hadPending = React.useRef(false)
React.useEffect(() => {
if (pending > 0) hadPending.current = true
else if (hadPending.current) {
hadPending.current = false
setNotice(
<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted)">
All edits reviewed. Select a passage to ask about it or rewrite it.
</span>,
)
}
}, [pending])
const rewrite = (sel: CanvasSelectionInfo, label: string) => {
const edits: Edit[] = []
for (const blockId of sel.blockIds) {
const block = doc.blocks.find((b) => b.id === blockId)
if (!block) continue
const from = blockId === sel.start.blockId ? sel.start.offset : 0
const to = blockId === sel.end.blockId ? sel.end.offset : block.text.length
const slice = block.text.slice(from, to)
for (const [re, insert, reason] of RULES) {
for (const m of slice.matchAll(re))
edits.push({ blockId, find: m[0], insert, reason, at: from + (m.index ?? 0) })
}
}
edits.sort(
(a, b) =>
sel.blockIds.indexOf(a.blockId) - sel.blockIds.indexOf(b.blockId) ||
(a.at ?? 0) - (b.at ?? 0),
)
if (edits.length === 0) {
setNotice(
<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted)">
That passage is already tight. Nothing to change.
</span>,
)
return
}
void stream(edits, label, `${edits.length} ${edits.length === 1 ? "edit" : "edits"} ready.`)
}
return (
<Canvas
className="h-[640px] w-full max-w-4xl"
title="Reading width for long answers"
blocks={doc.blocks}
onBlocksChange={doc.setBlocks}
suggestions={doc.suggestions}
onAccept={doc.accept}
onReject={doc.reject}
onAcceptAll={doc.acceptAll}
onRejectAll={doc.rejectAll}
onStop={() => {
run.current++
doc.setSuggestions((list) => list.filter((s) => s.status !== "streaming"))
setNotice(
<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted)">
Stopped. Kept the edits written so far.
</span>,
)
}}
versions={doc.versions}
currentVersionId={doc.currentVersionId}
onRestoreVersion={doc.restoreVersion}
notice={notice}
onAsk={(question, sel) => rewrite(sel, `Answering: ${question}`)}
onSelectionAction={(action, sel) => {
if (action === "rewrite") rewrite(sel, "Rewriting the selection")
if (action === "explain") {
const text =
EXPLAIN[sel.start.blockId] ??
"This passage states the heading above it in one sentence."
setNotice(<span className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted) text-pretty">{text}</span>)
}
}}
/>
)
}
/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
return (
<p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
{children}{" "}
<a
href={`https://chatcn.dev/docs/components/${slug}`}
target="_blank"
rel="noopener"
className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
>
chatcn.dev
</a>
</p>
)
}
/** The kit's own example, framed for the 21st preview. */
export default function CanvasPreview() {
return (
<FitFrame minWidth="38rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="flex w-full max-w-2xl justify-center">
<CanvasDemo />
</div>
<MoreOn slug="canvas">A writing canvas beside the chat, with suggestions. On</MoreOn>
</div>
</FitFrame>
)
}
















