Display

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, TriangleAlert } from "lucide-react"
import { CopyButton } from "@/registry/ui/copy-button"
import { cn } from "@/lib/utils"

/** Breathing room kept above a range that is too tall to centre. */
const SCROLL_MARGIN = 16
const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"

function subscribeReducedMotion(onChange: () => void) {
  if (typeof window === "undefined" || !window.matchMedia) return () => {}
  const query = window.matchMedia(REDUCED_MOTION_QUERY)

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/code-tour.json

Prompt

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 code string 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 scrollTop on its own pane via scrollTo; scrollIntoView is 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 tablist and the note is its tabpanel; 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.

On This Page