← ui

walkthrough

ui

a file told step by step: lines marked, changed, typed, another file brought forward

npx shadcn add @ag/walkthrough
Already installed? Preview an update
npx shadcn add @ag/walkthrough --dry-run

Your 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), and Walkthrough draws the frame at a moment in code'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 - with diff), 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 a Player (play, scrub, the steps as chapters), a Playback (plays once in view), give it a progress for a still, or use WalkthroughScroll, 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.
Updated 2026-11-05 · release notes

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

walkthrough.demo.tsx
"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

walkthrough.tsx
"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>
  )
}