About
A code diff that streams in as an agent writes the patch, then a per hunk review: accept or reject each change, fold what is done, and switch between unified and split views with word level highlights.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Diff Viewer to your project and fits it to your theme.
Code
How Diff Viewer 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 { DiffFile, DiffView, DiffViewer, HunkDecision, diffText } from "@/components/ui/diff-viewer"
/* -------------------------------------------------------------------------------------------------
* Formatting
* -----------------------------------------------------------------------------------------------*/
const range = (start: number, count: number) => (count === 1 ? `${start}` : `${start},${count}`)
/** Writes a file back out as unified diff text, the inverse of `parsePatch`. */
function formatPatch(file: DiffFile): string {
const oldPath = file.status === "added" ? "/dev/null" : `a/${file.oldPath ?? file.path}`
const newPath = file.status === "deleted" ? "/dev/null" : `b/${file.path}`
const out = [`diff --git a/${file.oldPath ?? file.path} b/${file.path}`]
if (file.status === "added") out.push("new file mode 100644")
if (file.status === "deleted") out.push("deleted file mode 100644")
if (file.status === "renamed" && file.oldPath) {
out.push(`rename from ${file.oldPath}`, `rename to ${file.path}`)
}
if (file.binary) {
out.push(`Binary files ${oldPath} and ${newPath} differ`)
return `${out.join("\n")}\n`
}
if (file.hunks.length > 0) out.push(`--- ${oldPath}`, `+++ ${newPath}`)
for (const hunk of file.hunks) {
const heading = hunk.heading ? ` ${hunk.heading}` : ""
out.push(
`@@ -${range(hunk.oldStart, hunk.oldLines)} +${range(hunk.newStart, hunk.newLines)} @@${heading}`,
)
for (const line of hunk.lines) {
out.push(`${line.kind === "add" ? "+" : line.kind === "remove" ? "-" : " "}${line.text}`)
}
}
return `${out.join("\n")}\n`
}
const BEFORE = `import type { Cart, Item } from "./types"
export function lineTotal(item: Item) {
return item.price * item.qty
}
export function subtotal(cart: Cart) {
return cart.items.reduce((s, i) => s + lineTotal(i), 0)
}
export function tax(cart: Cart) {
return subtotal(cart) * cart.rate
}
export function total(cart: Cart) {
const sum = subtotal(cart) + tax(cart)
return Math.round(sum * 100) / 100
}
`
const AFTER = `import { roundCents } from "./money"
import type { Cart, Item } from "./types"
export function lineTotal(item: Item) {
return roundCents(item.price * item.qty)
}
export function subtotal(cart: Cart) {
return cart.items.reduce((s, i) => s + lineTotal(i), 0)
}
export function tax(cart: Cart) {
return roundCents(subtotal(cart) * cart.rate)
}
export function total(cart: Cart) {
const sum = subtotal(cart) + tax(cart)
return roundCents(sum)
}
`
const PATH = "src/cart/totals.ts"
const FILE = diffText(BEFORE, AFTER, { path: PATH, context: 2 })
const PATCH = formatPatch(FILE)
/** How long a choice holds a tour before it carries on. */
const RESUME_MS = 1800
/** True until `RESUME_MS` after the last `hold()`, for tours with their own state. */
function useHold() {
const [held, setHeld] = React.useState(false)
const timer = React.useRef(0)
React.useEffect(() => () => window.clearTimeout(timer.current), [])
const hold = React.useCallback(() => {
setHeld(true)
window.clearTimeout(timer.current)
timer.current = window.setTimeout(() => setHeld(false), RESUME_MS)
}, [])
return [held, hold] as const
}
interface Beat {
view: DiffView
decisions: Record<string, HunkDecision>
ms: number
}
const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches
/** The patch arrives this many characters per tick, the way an agent writes it. */
const CHUNK = 14
const TICK_MS = 40
const [FIRST] = FILE.hunks
/** After the stream: review in unified, the first change accepted and folded, then both sides in split. */
const BEATS: Beat[] = [
{ view: "unified", decisions: {}, ms: 1000 },
{ view: "unified", decisions: FIRST ? { [FIRST.id]: "accepted" } : {}, ms: 1500 },
{ view: "split", decisions: {}, ms: 1800 },
]
/** A replay picks up where the first hunk starts, so it never shows an empty card. */
const RESTART = PATCH.indexOf("export function lineTotal")
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>
)
}
/** 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>
)
}
/**
* An agent's patch to a cart totals module streams in hunk by hunk, then the review plays: the
* first change is accepted and folds away so the next one moves up, and the same diff opens side
* by side with word level highlights. Loops. Click Accept, Reject or the view toggle and the tour
* waits on your choice for a moment.
*/
export default function DiffViewerPreview() {
// Opens mid stream, so the first frame already shows the patch landing.
const [length, setLength] = React.useState(Math.round(PATCH.length * 0.6))
const [beat, setBeat] = React.useState(-1)
const [held, hold] = useHold()
const [mine, setMine] = React.useState<Omit<Beat, "ms">>({ view: "unified", decisions: {} })
const streaming = beat === -1
React.useEffect(() => {
if (reducedMotion()) {
setBeat(0)
return
}
if (held) return
let id = 0
if (streaming) {
id = window.setTimeout(() => {
if (length >= PATCH.length) setBeat(0)
else setLength((n) => Math.min(PATCH.length, n + CHUNK))
}, TICK_MS)
} else {
id = window.setTimeout(() => {
if (beat >= BEATS.length - 1) {
setLength(RESTART)
setBeat(-1)
} else setBeat((b) => b + 1)
}, BEATS[beat]?.ms ?? 1200)
}
return () => window.clearTimeout(id)
}, [held, streaming, length, beat])
const tour = BEATS[Math.max(0, beat)] ?? BEATS[0]!
const current = held ? mine : tour
const take = (next: Partial<Omit<Beat, "ms">>) => {
hold()
// A click mid stream lands on the finished diff, so Accept and Reject have something to act on.
if (streaming) {
setLength(PATCH.length)
setBeat(0)
}
setMine({ view: current.view, decisions: current.decisions, ...next })
}
return (
<FitFrame minWidth="55rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="w-full max-w-[52rem]">
{streaming && !held ? (
<DiffViewer
className="relative h-[31.5rem]"
patch={PATCH.slice(0, length)}
path={PATH}
streaming
view="unified"
/>
) : (
<DiffViewer
className="relative h-[31.5rem]"
file={FILE}
review
view={current.view}
onViewChange={(view) => take({ view })}
decisions={current.decisions}
onDecisionChange={(id, decision) => {
const decisions = { ...current.decisions }
if (decision) decisions[id] = decision
else delete decisions[id]
take({ decisions })
}}
onDecideAll={(decision) =>
take({
decisions: decision
? Object.fromEntries(FILE.hunks.map((h) => [h.id, decision]))
: {},
})
}
/>
)}
</div>
<MoreOn slug="diff-viewer">
Streaming patches, per hunk review, unified and split views. On
</MoreOn>
</div>
</FitFrame>
)
}








