Recipe Step Progress

A scroll-spy gutter you pin alongside a long-form document. An IntersectionObserver watches a series of section ids and surfaces "which step is the user currently reading" as a thin column of dots or a continuous rail with a filled portion.

Different from the siblings: StepProgress is a discrete wizard cursor driven by a current index, StepTimeline is a labeled list of steps. This component is the scroll indicator — the cursor is whatever section the page is scrolled to.

Step 1 Introduction
Step 2 Prep ingredients
Step 3 Mix the dough
Step 4 Bake
Step 5 Serve
Customize
Variant
dots
Layout

Installation

npx shadcn@latest add https://craftbits.dev/r/recipe-step-progress.json

Usage

import { RecipeStepProgress } from "@craft-bits/core";
 
const steps = [
  { id: "intro", label: "Introduction" },
  { id: "prep", label: "Prep ingredients" },
  { id: "mix", label: "Mix the dough" },
  { id: "bake", label: "Bake" },
];
 
<RecipeStepProgress steps={steps} />

Section headings only need a stable id matching each step entry:

<section id="intro"></section>
<section id="prep"></section>

Switch to the continuous-rail variant:

<RecipeStepProgress steps={steps} variant="rail" />

Render horizontally (e.g. as a top-of-page bar):

<RecipeStepProgress steps={steps} orientation="horizontal" />

Drive it from the outside (controlled) when the active section is derived elsewhere:

<RecipeStepProgress
  steps={steps}
  activeId={activeHash}
  onActiveChange={setActiveHash}
/>

Understanding the component

  1. One observer, many sections. A single IntersectionObserver watches every section id. The active step is the topmost intersecting element — when several sections are visible at once, the one nearest the top wins. The default rootMargin frames a "now reading" band a third of the way down the viewport.
  2. Two variants. dots renders N small circles; the current one fills with accent and scales up; past dots stay tinted; future dots are muted. rail renders a single thin line plus a filled portion up to the current step.
  3. Smooth-spring transitions. The active marker rides on SPRINGS.smooth via layoutId — when the active step changes, the dot interpolates. Reduced-motion users see the marker repaint instantly.
  4. Click-to-scroll. When clickable is true (the default), each indicator is a real <button> that calls scrollIntoView({ behavior: "smooth" }). Keyboard navigation works out of the box.
  5. Controlled + uncontrolled. Pass activeId to drive the indicator from outside (URL hash, parent scroll-spy). Omit it and the component runs its own observer.
  6. Cleanup. The observer is disconnected in the useEffect return, and re-created when steps, rootMargin, or controlled-mode changes.

Props

PropTypeDefaultDescription
steps{ id: string; label?: string }[]requiredOrdered section ids the observer watches.
activeIdstringControlled active section id.
defaultActiveIdstringfirst stepUncontrolled initial active id.
onActiveChange(id: string) => voidFired when the active section changes.
rootMarginstring"-30% 0px -60% 0px"IntersectionObserver rootMargin.
variant"dots" | "rail""dots"Indicator geometry.
orientation"horizontal" | "vertical""vertical"Layout axis.
clickablebooleantrueWhen true, indicators are buttons that smooth-scroll on click.
classNamestringMerged onto the root <nav>.

Accessibility

  • Renders as <nav aria-label="Reading progress"> (override via aria-label).
  • Each indicator is a real <button> with aria-label and aria-current="location" on the active one.
  • Hit area is 24×24 px even when the visible dot is 8 px.
  • Focus-visible ring uses --cb-accent against --cb-bg.
  • prefers-reduced-motion: reduce short-circuits the spring.

Credits

  • Extracted from: terminal-dreams (src/components/recipe-scroller/RecipeStepProgress.tsx). The source took an externally-supplied activeId plus a scrollContainerRef and rendered numbered circles. The library version trades the parent-driven cursor for an internal IntersectionObserver (with a controlled escape hatch), adds a rail variant, supports both orientations, and repaints in --cb-* tokens.