"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
export type CodeTokenType = "keyword" | "string" | "comment" | "number" | "tag" | "attr" | "fn" | "type" | "punct" | "plain";
export interface CodeToken {
text: string;
type?: CodeTokenType;
}
/** A line of code: a string (tokenized for you), or pre-tokenized, optionally with a stable key. */
export type CodeLine = string | { key?: string; text?: string; tokens?: CodeToken[] };
export interface CodeWalkthroughStep {
/** Short step title, shown above the caption and on the step dots. */
title?: string;
/** What this step explains. */
caption: React.ReactNode;
/** The whole file at this step: one string, or an array of lines. */
code: string | CodeLine[];
/** 1-based lines to emphasise ("1-3, 7" or [1, 2, 3, 7]); the rest dims. Omit to show every line plainly. */
highlight?: string | number[];
}
export interface CodeWalkthroughProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
steps: CodeWalkthroughStep[];
/** Accessible name of the walkthrough. */
label?: string;
/** File name in the code panel header. */
fileName?: string;
/** Captions beside the code (stacking below it in narrow containers), always below, or not rendered ("none" — e.g. when you show captions elsewhere via onStepChange). */
captionPosition?: "right" | "bottom" | "none";
showLineNumbers?: boolean;
/** Opacity of lines outside the highlighted range. */
dimOpacity?: number;
/** Advance automatically (with a pause button). */
autoplay?: boolean;
/** Time per step while playing, in ms. */
interval?: number;
/** Wrap from the last step to the first (buttons, keys and autoplay). */
loop?: boolean;
/** Controlled step (0-based). */
step?: number;
defaultStep?: number;
onStepChange?: (step: number) => void;
accent?: string;
}
/* -------------------------------- tokenizer -------------------------------- */
const KEYWORDS = new Set(
"as async await break case catch class const continue default delete do else enum export extends false finally for from function if implements import in instanceof interface let new null of private public readonly return satisfies static super switch this throw true try type typeof undefined var void while yield".split(
" ",
),
);
const TYPE_WORDS = new Set(["string", "number", "boolean", "unknown", "never", "any", "object", "bigint", "symbol"]);
interface LexState {
block: boolean; // inside /* */
template: boolean; // inside a `template` that spans lines
inTag: boolean; // between < and > of a JSX tag
closing: boolean; // the current tag is </…>
tagBraces: number; // {…} depth inside the tag (attribute expressions)
jsx: number; // open JSX element depth
childBraces: number; // {…} depth inside JSX children
}
/** A deliberately tiny TS/TSX tokenizer: keywords, strings, comments, numbers, JSX tags and attributes. */
function tokenizeLine(line: string, state: LexState): CodeToken[] {
const out: CodeToken[] = [];
const push = (text: string, type: CodeTokenType) => {
if (!text) return;
const last = out[out.length - 1];
if (last && last.type === type) last.text += text;
else out.push({ text, type });
};
const n = line.length;
let i = 0;
if (state.block) {
const end = line.indexOf("*/");
if (end === -1) return [{ text: line, type: "comment" }];
push(line.slice(0, end + 2), "comment");
i = end + 2;
state.block = false;
} else if (state.template) {
let j = 0;
while (j < n && line[j] !== "`") j += line[j] === "\\" ? 2 : 1;
if (j >= n) return [{ text: line, type: "string" }];
push(line.slice(0, j + 1), "string");
i = j + 1;
state.template = false;
}
/** Whether a "<" here opens JSX (rather than a comparison or a generic). */
const jsxCanStart = () => {
for (let k = out.length - 1; k >= 0; k--) {
const t = out[k];
if (!t.text.trim()) continue;
if (t.type === "keyword") return true;
return t.type === "punct" && /[(=,?:&|{[>};]$/.test(t.text.trimEnd());
}
return true;
};
while (i < n) {
const ch = line[i];
const rest = line.slice(i);
// JSX children: plain text until a tag or an {expression}.
if (!state.inTag && state.jsx > 0 && state.childBraces === 0 && ch !== "<") {
if (ch === "{") {
state.childBraces++;
push("{", "punct");
i++;
continue;
}
const m = /^[^<{]+/.exec(rest);
push(m ? m[0] : ch, "plain");
i += m ? m[0].length : 1;
continue;
}
if (rest.startsWith("//")) {
push(rest, "comment");
break;
}
if (rest.startsWith("/*")) {
const end = line.indexOf("*/", i + 2);
if (end === -1) {
push(rest, "comment");
state.block = true;
break;
}
push(line.slice(i, end + 2), "comment");
i = end + 2;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
let j = i + 1;
while (j < n && line[j] !== ch) j += line[j] === "\\" ? 2 : 1;
if (j >= n && ch === "`") state.template = true;
push(line.slice(i, j + 1), "string");
i = j + 1;
continue;
}
if (/\d/.test(ch) && !/[\w$]/.test(line[i - 1] ?? "")) {
const m = /^(?:0x[\da-f_]+|\d[\d_]*(?:\.\d+)?(?:e[+-]?\d+)?n?)/i.exec(rest);
const num = m ? m[0] : ch;
push(num, "number");
i += num.length;
continue;
}
if (rest.startsWith("=>")) {
push("=>", "punct");
i += 2;
continue;
}
if (ch === "<" && !state.inTag) {
const m = /^<(\/?)([A-Za-z][\w.]*)?/.exec(rest);
const isFragment = rest.startsWith("<>") || rest.startsWith("</>");
if (m && (m[2] || isFragment) && jsxCanStart()) {
state.inTag = true;
state.closing = m[1] === "/";
state.tagBraces = 0;
push(state.closing ? "</" : "<", "punct");
i += state.closing ? 2 : 1;
if (m[2]) {
push(m[2], "tag");
i += m[2].length;
}
continue;
}
}
if (state.inTag && state.tagBraces === 0) {
if (rest.startsWith("/>")) {
push("/>", "punct");
state.inTag = false;
i += 2;
continue;
}
if (ch === ">") {
push(">", "punct");
state.inTag = false;
state.jsx = Math.max(0, state.jsx + (state.closing ? -1 : 1));
i++;
continue;
}
}
const id = /^[A-Za-z_$][\w$]*/.exec(rest);
if (id) {
const word = id[0];
const after = line.slice(i + word.length);
let type: CodeTokenType = "plain";
if (state.inTag && state.tagBraces === 0 && /^\s*=/.test(after)) type = "attr";
else if (KEYWORDS.has(word)) type = "keyword";
else if (TYPE_WORDS.has(word) || /^[A-Z]/.test(word)) type = "type";
else if (/^\s*(?:<[\w\s,[\]]*>)?\s*\(/.test(after)) type = "fn";
push(word, type);
i += word.length;
continue;
}
if (/\s/.test(ch)) {
const m = /^\s+/.exec(rest);
push(m ? m[0] : ch, "plain");
i += m ? m[0].length : 1;
continue;
}
// Brace depth decides whether ">" ends a tag and where JSX children resume.
if (ch === "{") {
if (state.inTag) state.tagBraces++;
else if (state.jsx > 0) state.childBraces++;
} else if (ch === "}") {
if (state.inTag) state.tagBraces = Math.max(0, state.tagBraces - 1);
else if (state.jsx > 0) state.childBraces = Math.max(0, state.childBraces - 1);
}
push(ch, "punct");
i++;
}
return out;
}
function tokenizeAll(lines: string[]): CodeToken[][] {
const state: LexState = { block: false, template: false, inTag: false, closing: false, tagBraces: 0, jsx: 0, childBraces: 0 };
return lines.map((line) => tokenizeLine(line, state));
}
/* ------------------------------ steps → lines ------------------------------ */
interface ResolvedLine {
key: string;
text: string;
tokens: CodeToken[];
/** New in this step (its key didn't exist in the previous one). */
added: boolean;
}
function parseHighlight(spec: string | number[] | undefined): Set<number> | null {
if (spec === undefined) return null;
if (Array.isArray(spec)) return new Set(spec);
const lines = new Set<number>();
for (const part of spec.split(",")) {
const [a, b] = part.split("-").map((s) => Number.parseInt(s.trim(), 10));
if (Number.isNaN(a)) continue;
for (let n = a; n <= (Number.isNaN(b ?? Number.NaN) ? a : b); n++) lines.add(n);
}
return lines;
}
/**
* Weighted longest common subsequence of two line lists, as index pairs. Lines weigh by their
* length, so a real line of code always wins over a stray blank line or a lone brace.
*/
function lcsPairs(a: string[], b: string[]): [number, number][] {
const weight = (line: string) => line.trim().length * 2 + 1;
const table = Array.from({ length: a.length + 1 }, () => new Float64Array(b.length + 1));
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + weight(a[i]) : Math.max(table[i + 1][j], table[i][j + 1]);
}
}
const pairs: [number, number][] = [];
for (let i = 0, j = 0; i < a.length && j < b.length; ) {
if (a[i] === b[j] && table[i][j] === table[i + 1][j + 1] + weight(a[i])) {
pairs.push([i, j]);
i++;
j++;
} else if (table[i + 1][j] >= table[i][j + 1]) i++;
else j++;
}
return pairs;
}
/**
* Gives every line a key that survives between steps: explicit keys win; otherwise unchanged lines
* (matched by an LCS on their text) keep the previous step's key, so only real edits animate.
*/
function resolveSteps(steps: CodeWalkthroughStep[]): ResolvedLine[][] {
let counter = 0;
let previous: ResolvedLine[] = [];
return steps.map((step) => {
const raw: CodeLine[] = typeof step.code === "string" ? step.code.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n") : step.code;
const texts = raw.map((l) => (typeof l === "string" ? l : (l.text ?? (l.tokens ?? []).map((t) => t.text).join(""))));
const auto = tokenizeAll(texts);
const keys: (string | null)[] = raw.map((l) => (typeof l === "string" ? null : (l.key ?? null)));
for (const [pi, ci] of lcsPairs(
previous.map((l) => l.text),
texts,
)) {
if (keys[ci] === null && !keys.includes(previous[pi].key)) keys[ci] = previous[pi].key;
}
const prevKeys = new Set(previous.map((l) => l.key));
const lines = raw.map((l, i) => {
const key = keys[i] ?? `l${counter++}`;
const tokens = typeof l !== "string" && l.tokens ? l.tokens : auto[i];
return { key, text: texts[i], tokens, added: previous.length > 0 && !prevKeys.has(key) && texts[i].trim() !== "" };
});
previous = lines;
return lines;
});
}
/* -------------------------------- component -------------------------------- */
const TOKEN_CLASS: Record<CodeTokenType, string> = {
keyword: "text-[#C2410C] dark:text-[#FF9470]",
string: "text-[#15803D] dark:text-[#9FE6BC]",
comment: "italic text-zinc-400 dark:text-zinc-500",
number: "text-[#1D4ED8] dark:text-[#93C5FD]",
tag: "text-[#0E7490] dark:text-[#67E8F9]",
attr: "text-[#A16207] dark:text-[#FCD34D]",
fn: "text-zinc-950 dark:text-white",
type: "text-[#0E7490] dark:text-[#67E8F9]",
punct: "text-zinc-500 dark:text-zinc-400",
plain: "text-zinc-800 dark:text-zinc-200",
};
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/**
* A presentation-style code walkthrough. Each step shows the file as it stands, emphasises a line
* range (the rest dims) and explains it in a caption. Lines added or removed between steps grow and
* collapse in place, matched by stable line keys so unchanged lines never jump. Navigate with the
* buttons, step dots, Left / Right / Home / End while focused, or let it autoplay with a pause.
* Syntax colours come from pre-tokenized lines or a tiny built-in TSX tokenizer, in both themes.
*/
export function CodeWalkthrough({
steps,
label = "Code walkthrough",
fileName = "example.tsx",
captionPosition = "right",
showLineNumbers = true,
dimOpacity = 0.35,
autoplay = false,
interval = 4000,
loop = false,
step: stepProp,
defaultStep = 0,
onStepChange,
accent = "#FF7448",
className = "",
style,
onKeyDown,
...rest
}: CodeWalkthroughProps) {
const reduced = usePrefersReducedMotion();
const resolved = React.useMemo(() => resolveSteps(steps), [steps]);
const count = steps.length;
const [internalStep, setInternalStep] = React.useState(defaultStep);
const step = Math.min(Math.max(0, stepProp ?? internalStep), Math.max(0, count - 1));
const [playing, setPlaying] = React.useState(autoplay);
const [prevAutoplay, setPrevAutoplay] = React.useState(autoplay);
if (prevAutoplay !== autoplay) {
setPrevAutoplay(autoplay);
setPlaying(autoplay);
}
const [onScreen, setOnScreen] = React.useState(true);
const [pageVisible, setPageVisible] = React.useState(true);
const rootRef = React.useRef<HTMLElement>(null);
const uid = React.useId();
const goTo = (next: number) => {
const target = loop ? (next + count) % count : Math.min(Math.max(0, next), count - 1);
if (target === step || count === 0) return;
if (stepProp === undefined) setInternalStep(target);
onStepChange?.(target);
};
// Autoplay pauses while off screen or in a hidden tab, and stops at the end unless looping.
React.useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting), { threshold: 0.25 });
io.observe(el);
const onVisibility = () => setPageVisible(!document.hidden);
document.addEventListener("visibilitychange", onVisibility);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
const running = playing && onScreen && pageVisible && count > 1;
// The latest "advance" closure, so the timer effect only restarts when the step or timing changes.
const advance = React.useRef(() => {});
React.useEffect(() => {
advance.current = () => {
if (step >= count - 1 && !loop) {
// Without looping, autoplay starts over from the first step.
if (stepProp === undefined) setInternalStep(0);
onStepChange?.(0);
} else goTo(step + 1);
};
});
React.useEffect(() => {
if (!running) return;
const id = window.setTimeout(() => advance.current(), interval);
return () => window.clearTimeout(id);
}, [running, step, interval]);
const lines = resolved[step] ?? [];
// Reserve room for the longest step so the controls don't jump as lines come and go.
const maxLines = resolved.reduce((m, l) => Math.max(m, l.length), 0);
const highlight = parseHighlight(steps[step]?.highlight);
const current = steps[step];
const spring = reduced ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 36, mass: 0.8 };
const atStart = !loop && step === 0;
const atEnd = !loop && step === count - 1;
const button =
"grid size-8 place-items-center rounded-lg border border-zinc-900/10 text-zinc-600 outline-hidden transition-colors hover:bg-zinc-900/[0.05] hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cw-accent)] disabled:pointer-events-none disabled:opacity-35 dark:border-white/10 dark:text-zinc-300 dark:hover:bg-white/[0.07] dark:hover:text-white";
const caption = (
<div className="min-w-0" aria-live="polite" aria-atomic="true">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={reduced ? { opacity: 1 } : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 1 } : { opacity: 0, y: -4 }}
transition={reduced ? { duration: 0 } : { duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
>
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[color-mix(in_srgb,var(--cw-accent)_75%,#52525b)] dark:text-[var(--cw-accent)]">
Step {String(step + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
</p>
{current?.title ? <p className="mt-2 text-[17px] font-semibold tracking-[-0.02em] text-zinc-950 dark:text-white">{current.title}</p> : null}
<div className="mt-2 text-[14px] leading-relaxed text-zinc-600 dark:text-zinc-400">{current?.caption}</div>
</motion.div>
</AnimatePresence>
</div>
);
return (
<section
ref={rootRef}
aria-roledescription="code walkthrough"
aria-label={label}
{...rest}
className={`@container w-full min-w-0 ${className}`}
style={{ ...style, ["--cw-accent" as string]: accent }}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
const map: Record<string, number> = { ArrowRight: step + 1, ArrowLeft: step - 1, Home: 0, End: count - 1 };
if (!(event.key in map)) return;
event.preventDefault();
setPlaying(false);
goTo(map[event.key]);
}}
>
<div className={`grid grid-cols-1 gap-5 ${captionPosition === "right" ? "@3xl:grid-cols-[minmax(0,1fr)_minmax(220px,280px)] @3xl:items-center" : ""}`}>
<div className="min-w-0 overflow-hidden rounded-xl border border-zinc-900/10 bg-[#FBFAF7] dark:border-white/10 dark:bg-[#111316]">
<div className="flex items-center gap-3 border-b border-zinc-900/10 px-4 py-2.5 dark:border-white/10">
<span aria-hidden="true" className="flex gap-1.5">
<span className="size-2 rounded-full bg-zinc-900/15 dark:bg-white/15" />
<span className="size-2 rounded-full bg-zinc-900/15 dark:bg-white/15" />
<span className="size-2 rounded-full bg-zinc-900/15 dark:bg-white/15" />
</span>
<span className="truncate font-mono text-[11px] text-zinc-500 dark:text-zinc-400">{fileName}</span>
<span className="ml-auto shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{lines.length} lines
</span>
</div>
<pre
tabIndex={0}
role="region"
aria-label={`${fileName}, step ${step + 1} of ${count}. Use the left and right arrow keys to change step.`}
aria-describedby={`${uid}-hint`}
style={{ minHeight: `calc(${maxLines} * 1.7em + 24px)` }}
className="overflow-x-auto py-3 font-mono text-[12.5px] leading-[1.7] outline-hidden focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--cw-accent)]"
>
<code className="block w-max min-w-full">
<AnimatePresence initial={false}>
{lines.map((line, index) => {
const lit = highlight === null || highlight.has(index + 1);
const emphasised = highlight !== null && lit;
return (
<motion.span
key={line.key}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: lit ? 1 : dimOpacity }}
exit={{ height: 0, opacity: 0 }}
transition={spring}
className="block overflow-hidden"
>
<span
className={`relative flex pr-5 transition-colors duration-300 ${emphasised ? "bg-[color-mix(in_srgb,var(--cw-accent)_9%,transparent)]" : ""}`}
>
<span
aria-hidden="true"
className={`absolute inset-y-0 left-0 w-[2px] transition-opacity duration-300 ${emphasised ? "bg-[var(--cw-accent)] opacity-100" : "opacity-0"}`}
/>
{showLineNumbers ? (
<span aria-hidden="true" className="w-11 shrink-0 select-none pr-3 text-right tabular-nums text-zinc-400 dark:text-zinc-600">
{index + 1}
</span>
) : (
<span aria-hidden="true" className="w-4 shrink-0" />
)}
<span aria-hidden="true" className="w-4 shrink-0 select-none text-[var(--cw-accent)]">
{line.added ? "+" : ""}
</span>
<span className="whitespace-pre">
{line.tokens.length
? line.tokens.map((t, k) => (
<span key={k} className={TOKEN_CLASS[t.type ?? "plain"] ?? TOKEN_CLASS.plain}>
{t.text}
</span>
))
: ""}
</span>
</span>
</motion.span>
);
})}
</AnimatePresence>
</code>
</pre>
</div>
{captionPosition === "right" ? caption : null}
</div>
{captionPosition === "bottom" ? <div className="mt-5">{caption}</div> : null}
<div className="mt-5 flex items-center gap-3">
<button type="button" aria-label="Previous step" disabled={atStart} className={button} onClick={() => { setPlaying(false); goTo(step - 1); }}>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="m10 4-4 4 4 4" />
</svg>
</button>
<div role="group" aria-label="Steps" className="flex flex-1 items-center gap-1.5">
{steps.map((s, i) => (
<button
key={i}
type="button"
aria-label={`Step ${i + 1}${s.title ? `: ${s.title}` : ""}`}
aria-current={i === step ? "step" : undefined}
onClick={() => {
setPlaying(false);
goTo(i);
}}
className="group relative grid h-6 min-w-6 flex-1 place-items-center rounded-md outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--cw-accent)]"
>
<span className="relative block h-[3px] w-full overflow-hidden rounded-full bg-zinc-900/10 transition-colors group-hover:bg-zinc-900/20 dark:bg-white/10 dark:group-hover:bg-white/20">
<span
key={i === step && running ? `run-${step}` : "idle"}
className="absolute inset-y-0 left-0 rounded-full bg-[var(--cw-accent)]"
style={
i < step || (i === step && !running)
? { width: "100%" }
: i === step && running && !reduced
? { width: "100%", animation: `${uid.replace(/[^a-zA-Z0-9_-]/g, "")}-fill ${interval}ms linear both` }
: { width: i === step ? "100%" : "0%" }
}
/>
</span>
</button>
))}
</div>
<button type="button" aria-label="Next step" disabled={atEnd} className={button} onClick={() => { setPlaying(false); goTo(step + 1); }}>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 4 4 4-4 4" />
</svg>
</button>
{autoplay ? (
<button type="button" aria-label={playing ? "Pause autoplay" : "Play autoplay"} aria-pressed={playing} className={button} onClick={() => setPlaying((p) => !p)}>
{playing ? (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<rect x="4" y="3.5" width="2.6" height="9" rx="0.8" />
<rect x="9.4" y="3.5" width="2.6" height="9" rx="0.8" />
</svg>
) : (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<path d="M5 3.6v8.8a.6.6 0 0 0 .9.5l7-4.4a.6.6 0 0 0 0-1l-7-4.4a.6.6 0 0 0-.9.5Z" />
</svg>
)}
</button>
) : null}
</div>
<p id={`${uid}-hint`} hidden>
Left and right arrow keys change step; Home and End jump to the first and last step.
</p>
{/* Keyframes for the autoplay progress bar (name scoped to this instance). */}
<style>{`@keyframes ${uid.replace(/[^a-zA-Z0-9_-]/g, "")}-fill{from{width:0%}to{width:100%}}`}</style>
</section>
);
}