Aadi

Canvas

AI chats 1

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>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

More in AI chats

View all
  • AI Actions

    AI Actions

    338
  • Illos Knowledge Sources

    Illos Knowledge Sources

    40
  • Aquarium Prompt

    Aquarium Prompt

    48
  • Attachments

    Attachments

    38
  • Chat Search

    Chat Search

    1
  • Color Picker

    Color Picker

    171
  • Commit

    Commit

    32
  • Message Thread

    Message Thread

    57

Explore more

Part of chatcn — browse the full library on 21st.dev.