Code Tour
An annotated walkthrough of one file — numbered steps that each highlight a line range, a note beside the code, and a pane that scrolls the active range into view.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/code-tour.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CodeTour" component: an annotated
walkthrough of ONE file. lucide-react for two chevrons and one alert icon, plus
any icon button that writes to navigator.clipboard. No syntax highlighter, no
editor, no virtualisation — the code is inert, selectable text.
Contract
- Export a forwardRef<HTMLDivElement> that spreads the remaining native div
props. Props: code: string (the whole file, never trimmed or re-indented —
line N of this string is line N of the tour), steps: CodeTourStep[],
value?: number, defaultValue = 0, onValueChange?: (index: number) => void
(controlled + uncontrolled active step), filename?: string, language = "tsx"
(sets the language-* class and the header fallback label only; nothing here
highlights), viewportHeight = 320 (max height in px of the scrolling code
pane — short files stay short), dimInactive = true, showStepList = true.
- CodeTourStep = { id?: string, title: string, note: ReactNode, from: number,
to?: number }. from/to are 1-BASED INCLUSIVE line numbers; to defaults to
from, so a one-line step is { from: 12 }. note is a ReactNode so a step can
carry a link or inline code.
- Resolve steps once per render against the split lines:
rawFrom = trunc(from); rawTo = trunc(to ?? from)
from = min(rawFrom, rawTo); to = max(rawFrom, rawTo) // written backwards
// is a typo, not a
// refusal
range = max(from,1) <= min(to,lineCount)
? { from: max(from,1), to: min(to,lineCount) }
: null // outside the file
NaN loses every comparison, so a nonsense range degrades to null instead of
highlighting something arbitrary. Keep the ORIGINAL numbers around: the
refusal message quotes them.
- The active index is clamped during render (never written back into state), so
a steps array that shrinks under a stale index shows the last step instead of
an empty panel. steps = [] is a legal tour: index -1, no highlight.
Behavior
- Selecting a step marks its line rows, fades the others when dimInactive, and
scrolls the pane so the range is visible. The maths, run against the pane
(which is position:relative, so it is the rows' offsetParent and offsetTop is
a content-box coordinate that does NOT move as the pane scrolls):
rangeTop = firstRow.offsetTop
rangeHeight = lastRow.offsetTop + lastRow.offsetHeight - rangeTop
view = pane.clientHeight
target = rangeHeight + 2*MARGIN >= view
? rangeTop - MARGIN // pin to the top
: rangeTop - (view - rangeHeight) / 2 // centre it
top = clamp(target, 0, pane.scrollHeight - view)
Pin rather than centre when the range is taller than the pane: centring would
push the range's FIRST line off screen, and that is where reading starts.
Skip the call entirely when |top - pane.scrollTop| < 1 so an already-correct
position never re-triggers a smooth scroll.
- Scroll with pane.scrollTo({ top, behavior }), NEVER element.scrollIntoView():
scrollIntoView walks up the ancestor chain and scrolls the page too, yanking
the reader away from the tour they are reading.
- behavior is "smooth" only when the change is a real step change AND the user
has not asked for reduced motion; the FIRST scroll after mount is always
"auto" (a ref raised inside the effect), so the tour lands in place instead
of gliding while the reader is still finding it. Read the media query through
useSyncExternalStore(subscribe, getSnapshot, () => false) — the server has no
media queries, and the listener unsubscribes with the component.
- Re-selecting the ALREADY active step changes no state, so no effect runs:
re-centre synchronously inside the click handler, or the click looks dead
after the reader has scrolled the pane by hand.
- Keyboard map:
Jump list (role=tablist, aria-orientation=vertical):
ArrowDown / ArrowRight -> next step, ArrowUp / ArrowLeft -> previous,
Home / End -> first / last. Each moves focus AND selection, and calls
preventDefault so the root shortcut below stays out of the way.
Anywhere else inside the component EXCEPT the code pane:
ArrowLeft / ArrowRight -> previous / next step, focus unchanged.
Code pane: nothing is intercepted — it is a scroll container with
tabIndex=0, so arrows, Home/End, PageUp/PageDown scroll it natively.
Nothing wraps at the ends: a tour is a linear reading order, and looping from
the last step back to the first tells the reader a story that isn't there.
- The prev/next pair uses aria-disabled + a handler guard at the ends, never
the native disabled attribute: the browser blurs a control the instant it
goes disabled, and these two die under the reader's finger exactly when they
are being clicked repeatedly.
- Steps whose range resolved to null stay in the list and stay selectable. The
note panel then shows an alert line — "This step points at lines 24-30, but
the file has 8 lines" — and nothing is highlighted. A tour that went stale
after an edit must say so, not silently point at the wrong code.
Rendering & styling
- Semantic tokens only: bg-card shell, bg-muted/40 header, border dividers,
bg-primary/10 + a 2px bg-primary bar for the active rows, bg-accent /
text-accent-foreground for the selected step in the list, bg-primary +
text-primary-foreground for the step number badge, text-muted-foreground for
the gutter, filename, counter and note body, text-destructive for the
out-of-range line. cn() merges the consumer's className everywhere.
- Rows: a flex div per line inside a real <pre><code class="language-*"> (the
semantic pair for source text — copy keeps the line breaks and assistive tech
announces it as code; give <pre> m-0 p-0 or a prose stylesheet adds margins).
The pane is overflow-auto with max-height:var(--code-tour-h) fed from
viewportHeight; <pre> gets w-max + min-w-full so a highlighted row paints
across the whole scrollable width instead of stopping at the fold.
- The line-number gutter is select-none + aria-hidden with a fixed
calc(<digits>ch + padding) width, so numbers stay right-aligned across rows,
never join a drag selection, and never reach a screen reader. Blank lines get
NO filler character: the gutter already holds the row open, and a copied
blank line stays blank.
- Inactive rows dim with opacity, not with a mask or an overlay: opacity leaves
the text in the flow, so every character of the file stays selectable and
copyable at every step. Fade with transition-opacity +
motion-reduce:transition-none.
- Accessibility: the jump list is role=tablist with roving tabIndex (0 on the
selected tab, -1 on the rest) and aria-selected; the note panel is the
role=tabpanel, aria-labelledby the active tab, tabIndex=0 because it is prose
with nothing focusable inside. The code pane is a separate role=group with
aria-label "<filename>, source". The number badge and the "L12-18" chip in
the list are aria-hidden so a tab's accessible name is just its title — the
panel states the range in full. Because prev/next move the note WITHOUT
moving focus, an aria-live=polite sr-only region mirrors "Step 3 of 5,
<title>, lines 12 to 18". With showStepList={false} there is no tablist, so
the panel degrades to role=group with its own aria-label rather than
referencing a tab that does not exist.
Customization levers
- Highlighting: the line text is one <span>{text}</span>. Swap it for a
tokenizer's output (or a Shiki-rendered line) and everything else — the
ranges, the scroll maths, the gutter — keeps working untouched.
- Layout: the note column is a container query (@[44rem]:flex-row, w-72). Widen
it for long prose, drop it below the code for a narrow embed, or set
showStepList={false} and drive the tour from the page's own controls through
value/onValueChange.
- Emphasis: bg-primary/10 reads as "look here". Swap to var(--chart-2) tones
for "this is new" or bg-destructive/10 for "this is the bug". Raise the
opacity-40 on inactive rows for a softer contrast, or set dimInactive={false}
when the file is short enough to read whole.
- Density: text-[13px]/leading-6 and viewportHeight are the two knobs for how
much code is on screen; the scroll maths reads clientHeight at call time, so
changing either needs no other edit.
- Copy: the header button copies the whole file. Point it at the active range
instead (code.split("\n").slice(from-1, to).join("\n")) when the tour is a
recipe people are meant to lift step by step.Concepts
- A step is a line range, not a snippet — every step points into the one
codestring with 1-based inclusive line numbers, so the file is never cut into pieces and line 12 stays line 12 in every step. - Centre or pin — a range that fits is centred in the pane; a range taller than the pane is pinned to the top with a margin, because centring it would push its first line off screen, and that is where reading starts.
- One scroll owner — the component only ever writes
scrollTopon its own pane viascrollTo;scrollIntoViewis deliberately avoided because it scrolls every ancestor, including the page the reader is on. - Dim, never mask — inactive lines fade with
opacity, so they stay in the text flow and the whole file remains selectable and copyable at every step, instead of sitting under an overlay that eats the selection. - Stale ranges refuse out loud — a step pointing past the end of the file (a tour that outlived an edit) stays in the list, stays selectable, and says which lines it wanted and how long the file actually is, rather than silently highlighting the nearest thing.
- Jump list as a tablist — the numbered list is a roving-tabindex
tablistand the note is itstabpanel; because the header's prev/next pair changes the panel without moving focus, a polite live region carries the change that focus would otherwise have announced.
Flame Graph
A four-state profiling flame graph — bar width is time, rows are stack depth, click to zoom with a breadcrumb back, and a search that highlights matches across the whole profile with a matched-time readout.
Env Diff
A key × environment configuration diff — every cell judged against one baseline column, secrets compared by fingerprint so no value is ever rendered, and a live count of what promoting one environment into another would change.