a file told step by step: lines marked, changed, typed, another file brought forward
npx shadcn add @ag/walkthroughAlready installed? Preview an update
npx shadcn add @ag/walkthrough --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-11-05
- New: a file told step by step. Steps are data (
walkthroughTimeline(steps): a file, its text at a new state, keys typed into it, lines to mark, a diff, the step's words and a live view), andWalkthroughdraws the frame at a moment incode's frame and colours: the band over the marked lines travels from step to step and the file scrolls to it, changed lines fold and unfold (signed+and-withdiff), keys type with their caret, another file fades in, and each step's view ((moment) => node, scroll-story's chapter shape) plays beside it. It has no clock: put it in aPlayer(play, scrub, the steps as chapters), aPlayback(plays once in view), give it aprogressfor a still, or useWalkthroughScroll, the scrollytelling layout, its words beside the pinned file and each step an act cueing its chapter; stacked on a narrow window and under reduced motion, each step a still of its last frame.
also called scrollycoding, code tour, tutorial
shelves text & code, motion
comes with walkthrough-session
uses clip, code, card, playback, scroll-stage, scroll-story, toggle-group
the demo above, verbatim
"use client"
import { Sample } from "@/app/samples"
import {
walkthroughRest,
walkthroughTimeline,
} from "@/registry/base-nova/lib/walkthrough-session"
import { Playback } from "@/registry/base-nova/ui/playback"
import { Player } from "@/registry/base-nova/ui/player"
import {
Walkthrough,
WalkthroughScroll,
type WalkthroughSteps,
} from "@/registry/base-nova/ui/walkthrough"
// #region greet
const GREETING = `export function Greeting() {
return <p className="text-2xl">hello</p>
}`
const SAYS_NAME = `export function Greeting({ name }: { name: string }) {
return <p className="text-2xl">hello, {name}</p>
}`
const PAGE = `import { Greeting } from "./greeting"
export default function Page() {
return <Greeting name="ada" />
}`
/** What the file draws, live beside it: the step's view, at its progress. */
const Said = ({ text }: { text: string }) => (
<p className="font-semibold text-2xl tracking-tight">{text}</p>
)
const GREET_STEPS: WalkthroughSteps = [
{
title: "a component",
file: "greeting.tsx",
text: GREETING,
marks: "1",
words: "One function that draws one paragraph.",
view: () => <Said text="hello" />,
},
{
title: "what it draws",
marks: "2",
words: "The paragraph is all it returns, so it is all the page shows.",
},
{
title: "a name",
type: [{ text: "{ name }: { name: string }", line: 1, column: 26 }],
marks: "1",
words: "It takes a name, typed into its signature.",
},
{
title: "say it",
text: SAYS_NAME,
diff: true,
marks: "2",
words: "The line that changed, as a diff: the old one out, the new one in.",
// The name arrives letter by letter as the step plays.
view: ({ p }) => (
<Said text={`hello, ${"ada".slice(0, Math.round(p * 3))}`} />
),
},
{
title: "use it",
file: "page.tsx",
text: PAGE,
marks: "4",
words: "Another file, the page that hands it the name.",
},
]
const GREET = walkthroughTimeline(GREET_STEPS)
// #endregion
// #region server
const SERVER_V1 = `import { createServer } from "node:http"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
const PORT = Number(process.env.PORT ?? 3000)
const ROOT = join(import.meta.dirname, "public")
type Handler = (url: URL) => Promise<Response>
const TYPES: Record<string, string> = {
html: "text/html",
css: "text/css",
js: "text/javascript",
svg: "image/svg+xml",
}
function typeOf(path: string): string {
const ext = path.split(".").at(-1) ?? ""
return TYPES[ext] ?? "application/octet-stream"
}
const routes: Record<string, Handler> = {
"/health": async () => new Response("ok"),
"/time": async () => Response.json({ now: Date.now() }),
}
async function files(url: URL): Promise<Response> {
const path = join(ROOT, url.pathname)
if (!path.startsWith(ROOT)) return new Response("no", { status: 403 })
const body = await readFile(path)
return new Response(body, {
headers: { "content-type": typeOf(path) },
})
}
async function handle(url: URL): Promise<Response> {
const route = routes[url.pathname]
if (route) return route(url)
return files(url)
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", \`http://\${req.headers.host}\`)
const answer = await handle(url)
res.writeHead(answer.status, Object.fromEntries(answer.headers))
res.end(Buffer.from(await answer.arrayBuffer()))
})
server.listen(PORT, () => {
console.log(\`listening on \${PORT}\`)
})`
// A missing file answered, not thrown: the same file, its handler changed.
const SERVER_V2 = SERVER_V1.replace(
" const body = await readFile(path)\n",
' const body = await readFile(path).catch(() => null)\n if (!body) return new Response("not found", { status: 404 })\n',
)
const SERVER_STEPS: WalkthroughSteps = [
{
title: "a small server",
file: "server.ts",
text: SERVER_V1,
marks: "1-3",
words:
"Fifty lines of Node: no framework, the standard library and nothing else.",
},
{
title: "routes are data",
marks: "22-25",
words:
"A route is a path and a function that answers it. Adding one is adding a line.",
},
{
title: "everything else is a file",
marks: "27-34",
words:
"What no route answers is read from disk, and a path that climbs out of the folder is refused.",
},
{
title: "answered on the way out",
marks: "42-47",
words:
"Each request becomes a URL, the answer a standard Response, written back to Node's socket.",
},
{
title: "a missing file",
text: SERVER_V2,
diff: true,
marks: "30-31",
words:
"A file that is not there would throw. Now it answers 404, two lines in place of one.",
},
{
title: "and its test",
file: "server.test.ts",
text: `import { expect, test } from "bun:test"
test("a missing file is a 404", async () => {
const res = await fetch("http://localhost:3000/nope.css")
expect(res.status).toBe(404)
})`,
marks: "3-6",
words: "The change, asked for from outside.",
},
]
const SERVER = walkthroughTimeline(SERVER_STEPS)
// #endregion
export default function Demo() {
return (
<div className="space-y-16">
<Sample
name="playback"
glance="large"
title="plays once in view"
with="server"
>
<Playback clip={SERVER.clip}>
<Walkthrough timeline={SERVER} rows={8} />
</Playback>
</Sample>
<Sample
name="player"
title="in a player"
description="play, pause, scrub and the steps as chapters; each step's view live beside the file"
with="greet"
>
<Player clip={GREET.clip} label="a greeting, step by step">
<Walkthrough timeline={GREET} />
</Player>
</Sample>
<Sample
name="scroll"
title="on the scroll"
description="each step an act of a scroll stage, its words beside the pinned file; stacked where it does not pin"
with="server"
presentation
>
<WalkthroughScroll timeline={SERVER} label="a small server" rows={18} />
</Sample>
<Sample
name="still"
title="a still"
description="a fixed progress: the step's last frame"
with="greet"
>
<Walkthrough timeline={GREET} progress={walkthroughRest(GREET, 3)} />
</Sample>
</div>
)
}the source, every file an install writes
"use client"
// A WALKTHROUGH drawn at a moment: a file told step by step (lib/walkthrough-session),
// its lines in `code`'s own frame and colours, the marked lines under a band that
// travels from step to step, the lines that change folding and unfolding, keys typed
// with their caret, another file brought forward; beside it what the step shows live
// (a view, a function of its progress, as scroll-story's chapters draw), under it the
// step's words. It draws; something else moves time: inside `<Player
// clip={timeline.clip}>` it plays and scrubs, inside a `Playback` it plays once in
// view, `WalkthroughScroll` cues it step by step from a scroll stage with the words
// beside it, and a fixed `progress` is a still. No clock of its own.
//
// const TOUR = walkthroughTimeline([
// { title: "the file", file: "greet.ts", text: V1, marks: "1", words: <p>…</p> },
// { title: "its answer", marks: "2", words: <p>…</p> },
// { title: "say the name", text: V2, marks: "2", diff: true },
// ])
// <Player clip={TOUR.clip} label="greet"><Walkthrough timeline={TOUR} /></Player>
//
// A long file keeps the band in sight: the frame shows `rows` lines and scrolls to
// the band's middle as it travels, reading its own line height and its own height
// from the page, the facts the scroll is decided by.
import * as React from "react"
import { cn } from "@/lib/utils"
import { chapterSpan } from "@/registry/base-nova/lib/clip"
import { type CodeColours, runsOf } from "@/registry/base-nova/lib/code-tokens"
import {
type WalkthroughFrame,
type WalkthroughLine,
type WalkthroughPane,
type WalkthroughSign,
type WalkthroughStep,
type WalkthroughTimeline,
walkthroughFrameAt,
walkthroughRest,
} from "@/registry/base-nova/lib/walkthrough-session"
import { Card } from "@/registry/base-nova/ui/card"
import { CodeFrame } from "@/registry/base-nova/ui/code-frame"
import { Playback, usePlayhead } from "@/registry/base-nova/ui/playback"
import {
Act,
ScrollStage,
useAct,
useScrollStage,
} from "@/registry/base-nova/ui/scroll-stage"
import type { StoryMoment } from "@/registry/base-nova/ui/scroll-story"
import {
ToggleGroup,
ToggleGroupItem,
} from "@/registry/base-nova/ui/toggle-group"
import { useCodeColoursOf } from "@/registry/base-nova/ui/use-code-colours"
/** What a step shows live beside its file: scroll-story's chapter content, drawn at
* the step's moment (`p` its progress, `active` while it is the step on). */
export type WalkthroughView = (moment: StoryMoment) => React.ReactNode
/** Steps as `walkthrough` draws them: their words React nodes, their views live.
* Typed so, `walkthroughTimeline(STEPS)` reads every view's moment. */
export type WalkthroughSteps = readonly WalkthroughStep<
React.ReactNode,
WalkthroughView
>[]
/** Steps timed for this view: `walkthroughTimeline(steps)`. */
export type WalkthroughTimed = WalkthroughTimeline<
React.ReactNode,
WalkthroughView
>
/** The lines shown before the file scrolls, unless `rows` says. */
const ROWS = 16
/** A line's sign easing in and out, the one motion not drawn by the instant: a
* diff's tint (code.css) fading as the next step folds it, never cut. */
const SIGN_MOTION =
"transition-[background-color,border-color] duration-300 motion-reduce:transition-none"
/** The step's words: its title, then what it says. */
const TITLE = "font-semibold text-lg tracking-tight"
const WORDS = "text-[0.9375rem] text-foreground/70 leading-relaxed"
export interface WalkthroughProps {
/** The walkthrough, timed: `walkthroughTimeline(steps)`, whose `clip` the driver
* around it plays. */
timeline: WalkthroughTimed
/** The moment drawn, 0..1, for a still. Inside a driver omit it. */
progress?: number
/** The lines the file shows before it scrolls. The frame is as tall as the
* longest state when it is shorter. */
rows?: number
/** The step's title and words under the frame. Off where the page sets them
* itself (`WalkthroughScroll` beside it). */
words?: boolean
/** Its face: the page's frame, or the one glass material. */
material?: "plain" | "glass"
className?: string
}
export function Walkthrough({
timeline,
progress,
rows = ROWS,
words = true,
material = "plain",
className,
}: WalkthroughProps) {
const { at, driven } = usePlayhead(progress)
const frame = walkthroughFrameAt(timeline, at * timeline.total)
// Every state's colours, read once in the browser: plain until they load.
const colours = useCodeColoursOf(timeline.states)
const code = timeline.plans.some((p) => p.path !== null)
const views = timeline.steps.some((s) => s.view !== undefined)
return (
<div
data-slot="walkthrough"
className={cn("@container flex min-w-0 flex-col gap-4", className)}
>
<div
className={cn(
"grid min-w-0 gap-4",
code && views && "@3xl:grid-cols-2",
)}
>
{code && (
<Lines
pane={frame.pane}
colours={colours}
rows={Math.min(rows, timeline.lines)}
material={material}
/>
)}
{views && (
<Views
timeline={timeline}
frame={frame}
driven={driven}
material={material}
/>
)}
</div>
{words && <Words timeline={timeline} frame={frame} />}
</div>
)
}
/** The file: `code`'s frame (its bar, copy, face), the lines drawn here. */
function Lines({
pane,
colours,
rows,
material,
}: {
pane: WalkthroughPane | null
colours: readonly (CodeColours | null)[]
rows: number
material: "plain" | "glass"
}) {
const scroller = React.useRef<HTMLDivElement>(null)
const sheet = React.useRef<HTMLElement>(null)
// The band's middle kept in the middle of the frame: from the line height and
// the frame's height as the page draws them. The instant decides what is in
// view, so the wheel never scrolls it (`overflow-y-hidden` below): over a scroll
// stage the wheel is the page's, and the page's scroll is what moves the steps.
const focus = pane?.focus ?? 0
const path = pane?.path
// biome-ignore lint/correctness/useExhaustiveDependencies: a new file is scrolled even when its line is the last one's
React.useLayoutEffect(() => {
const box = scroller.current
const code = sheet.current
if (!box || !code)
throw new Error("walkthrough: the frame drew without its lines")
const line = Number.parseFloat(getComputedStyle(code).lineHeight)
if (!(line > 0))
throw new Error(`walkthrough: a line ${line} px tall is no line`)
const top =
code.getBoundingClientRect().top -
box.getBoundingClientRect().top +
box.scrollTop
box.scrollTop = top + focus * line - box.clientHeight / 2
}, [focus, path])
const caret = pane?.caret ?? null
return (
<CodeFrame
source={pane?.source ?? ""}
html={null}
lang={pane?.lang ?? "text"}
filename={pane?.path ?? ""}
copy={pane !== null}
material={material}
scroller={scroller}
className="min-w-0 [&_.ag-code-scroll]:overflow-y-hidden"
>
<pre style={{ opacity: pane?.opacity ?? 0 }}>
<code
ref={sheet}
data-slot="walkthrough-lines"
className="relative h-(--ag-walkthrough-rows)"
style={
{
"--ag-walkthrough-rows": `${rows}lh`,
} as React.CSSProperties
}
>
{pane?.bands.map((b, i) => (
<span
// The bands are paired by place, step to step: the first travels
// to the next first.
// biome-ignore lint/suspicious/noArrayIndexKey: a band's identity is its order
key={i}
aria-hidden
data-slot="walkthrough-band"
className="line highlighted pointer-events-none absolute top-0 left-0 h-lh origin-top"
style={{ transform: `translateY(${b.from}lh) scaleY(${b.size})` }}
/>
))}
{pane?.rows.map((r, i) => (
<Row
// biome-ignore lint/suspicious/noArrayIndexKey: a row is its place; what it holds is drawn from the instant
key={i}
line={r.line}
colours={colours[r.line.state] ?? null}
open={r.open}
sign={r.sign}
caret={caret?.row === i ? caret.column : null}
/>
))}
</code>
</pre>
</CodeFrame>
)
}
/** Each character's place in its state, for a line that starts at one. */
const placesOf = (line: WalkthroughLine) =>
typeof line.at === "number"
? Array.from(line.text, (_, i) => (line.at as number) + i)
: line.at
/** One line: `code`'s `.line` (its padding, its gutter, a diff's sign and tint),
* as tall as it is open. */
const Row = React.memo(function Row({
line,
colours,
open,
sign,
caret,
}: {
line: WalkthroughLine
colours: CodeColours | null
open: number
sign: WalkthroughSign
caret: number | null
}) {
const runs = React.useMemo(
() => runsOf(line.text, colours, placesOf(line))[0] ?? [],
[line, colours],
)
const parts: React.ReactNode[] = []
let at = 0
const ink = (text: string, run: (typeof runs)[number], key: string) =>
run.ink ? (
<span
key={key}
style={
{
"--shiki-light": run.ink.light,
"--shiki-dark": run.ink.dark,
fontStyle: run.ink.italic ? "italic" : undefined,
fontWeight: run.ink.bold ? 700 : undefined,
} as React.CSSProperties
}
>
{text}
</span>
) : (
text
)
const bar = (
<span
key="caret"
aria-hidden
data-slot="walkthrough-caret"
className="-mr-0.5 inline-block h-lh w-0.5 bg-current align-top"
/>
)
for (const [n, run] of runs.entries()) {
const end = at + run.text.length
if (caret !== null && caret >= at && caret < end) {
const cut = caret - at
if (cut > 0) parts.push(ink(run.text.slice(0, cut), run, `${n}a`))
parts.push(bar)
parts.push(ink(run.text.slice(cut), run, `${n}b`))
} else parts.push(ink(run.text, run, String(n)))
at = end
}
if (caret !== null && caret >= at) parts.push(bar)
return (
<span
data-slot="walkthrough-line"
className={cn(
"line relative block overflow-hidden",
sign && `diff ${sign}`,
SIGN_MOTION,
)}
style={{ height: `${open}lh`, opacity: open < 1 ? open : undefined }}
>
{parts}
</span>
)
})
/** The step's view: each step's own, kept by the steps after it that name none,
* crossfading as a step brings another. All laid in one cell, so the card is as
* tall as the tallest and never changes height. */
function Views({
timeline,
frame,
driven,
material,
}: {
timeline: WalkthroughTimed
frame: WalkthroughFrame
driven: boolean
material: "plain" | "glass"
}) {
const steps = timeline.steps
const owner = (i: number) => {
for (let j = i; j >= 0; j--) if (steps[j]?.view) return j
return -1
}
const now = owner(frame.step)
const was = frame.step > 0 ? owner(frame.step - 1) : now
const ambient = frame.step + frame.p
return (
<Card
material={material}
bodyClassName="grid min-h-0 place-items-center p-(--card-pad)"
>
{steps.map((s, j) => {
if (!s.view) return null
const shown =
j === now
? now === was
? 1
: frame.arrive
: j === was
? 1 - frame.arrive
: 0
const moment: StoryMoment = {
p: j === frame.step ? frame.p : j < frame.step ? 1 : 0,
ambient,
active: j === now,
driven,
}
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: a step's view is its step
key={j}
data-slot="walkthrough-view"
inert={j !== now}
aria-hidden={j !== now || undefined}
className="col-start-1 row-start-1 w-full"
style={{
opacity: shown,
visibility: shown === 0 ? "hidden" : undefined,
}}
>
{s.view(moment)}
</div>
)
})}
</Card>
)
}
/** The step's title and words, the last ones fading as these arrive, all laid in
* one cell so nothing below moves between steps. */
function Words({
timeline,
frame,
}: {
timeline: WalkthroughTimed
frame: WalkthroughFrame
}) {
return (
<div data-slot="walkthrough-words" className="grid">
{timeline.steps.map((s, i) => {
const shown =
i === frame.step
? i === 0
? 1
: frame.arrive
: i === frame.step - 1
? 1 - frame.arrive
: 0
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: a step's words are its step
key={i}
inert={i !== frame.step}
aria-hidden={i !== frame.step || undefined}
className="col-start-1 row-start-1 space-y-2"
style={{
opacity: shown,
visibility: shown === 0 ? "hidden" : undefined,
}}
>
<StepWords step={s} />
</div>
)
})}
</div>
)
}
function StepWords({ step }: { step: WalkthroughSteps[number] }) {
return (
<>
<h3 className={TITLE}>{step.title}</h3>
{step.words && <div className={WORDS}>{step.words}</div>}
</>
)
}
export interface WalkthroughScrollProps {
timeline: WalkthroughTimed
/** Its name, for assistive tech and the steps' picker. */
label: string
rows?: number
/** Scroll per step, a CSS length (scroll-stage's `pace`). */
pace?: string
material?: "plain" | "glass"
className?: string
}
/** The walkthrough told on the scroll: its words beside the pinned frame, each step
* an act of a scroll stage cueing its own chapter, so the frame plays the step as
* it arrives and waits on its last frame. Where the stage does not pin (a narrow
* window, reduced motion) the steps stand one under another, each its words and a
* still of its last frame. */
export function WalkthroughScroll({
timeline,
label,
rows,
pace = "90svh",
material,
className,
}: WalkthroughScrollProps) {
return (
<ScrollStage
acts={timeline.steps.length}
pace={pace}
aria-label={label}
className={className}
stacked={
<div data-slot="walkthrough-steps" className="space-y-12">
{timeline.steps.map((s, i) => (
<section
// biome-ignore lint/suspicious/noArrayIndexKey: a step is its place
key={i}
aria-label={s.title}
className="space-y-4"
>
<div className="space-y-2">
<StepWords step={s} />
</div>
<Walkthrough
timeline={timeline}
progress={walkthroughRest(timeline, i)}
words={false}
rows={rows}
material={material}
/>
</section>
))}
</div>
}
>
<Pinned
timeline={timeline}
label={label}
rows={rows}
material={material}
/>
</ScrollStage>
)
}
function Pinned({
timeline,
label,
rows,
material,
}: Omit<WalkthroughScrollProps, "pace" | "className">) {
const act = useAct()
const { seek } = useScrollStage()
return (
<div className="grid w-full items-center gap-10 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)]">
<div className="space-y-8">
<div className="grid">
{timeline.steps.map((s, i) => (
<Act
// biome-ignore lint/suspicious/noArrayIndexKey: a step is its act
key={i}
index={i}
inert={act !== i}
aria-hidden={act !== i || undefined}
className="col-start-1 row-start-1 space-y-2 opacity-0 transition-opacity duration-500 data-on:opacity-100 motion-reduce:transition-none"
>
<StepWords step={s} />
</Act>
))}
</div>
{/* One step of several, the one on stage lit: a dot each. */}
<ToggleGroup
variant="dots"
aria-label={label}
value={String(act)}
onValueChange={(v) => seek(Number(v))}
>
{timeline.steps.map((s, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: a step is its act
<ToggleGroupItem key={i} value={String(i)}>
go to {s.title}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Playback clip={timeline.clip} cue={chapterSpan(timeline.clip, act)}>
<Walkthrough
timeline={timeline}
words={false}
rows={rows}
material={material}
/>
</Playback>
</div>
)
}