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.
Customize
Variant
dots
Layout
Installation
npx shadcn@latest add https://craftbits.dev/r/recipe-step-progress.jsonUsage
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
- One observer, many sections. A single
IntersectionObserverwatches 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 defaultrootMarginframes a "now reading" band a third of the way down the viewport. - Two variants.
dotsrenders N small circles; the current one fills with accent and scales up; past dots stay tinted; future dots are muted.railrenders a single thin line plus a filled portion up to the current step. - Smooth-spring transitions. The active marker rides on
SPRINGS.smoothvialayoutId— when the active step changes, the dot interpolates. Reduced-motion users see the marker repaint instantly. - Click-to-scroll. When
clickableis true (the default), each indicator is a real<button>that callsscrollIntoView({ behavior: "smooth" }). Keyboard navigation works out of the box. - Controlled + uncontrolled. Pass
activeIdto drive the indicator from outside (URL hash, parent scroll-spy). Omit it and the component runs its own observer. - Cleanup. The observer is disconnected in the
useEffectreturn, and re-created whensteps,rootMargin, or controlled-mode changes.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
steps | { id: string; label?: string }[] | required | Ordered section ids the observer watches. |
activeId | string | — | Controlled active section id. |
defaultActiveId | string | first step | Uncontrolled initial active id. |
onActiveChange | (id: string) => void | — | Fired when the active section changes. |
rootMargin | string | "-30% 0px -60% 0px" | IntersectionObserver rootMargin. |
variant | "dots" | "rail" | "dots" | Indicator geometry. |
orientation | "horizontal" | "vertical" | "vertical" | Layout axis. |
clickable | boolean | true | When true, indicators are buttons that smooth-scroll on click. |
className | string | — | Merged onto the root <nav>. |
Accessibility
- Renders as
<nav aria-label="Reading progress">(override viaaria-label). - Each indicator is a real
<button>witharia-labelandaria-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-accentagainst--cb-bg. prefers-reduced-motion: reduceshort-circuits the spring.
Credits
- Extracted from:
terminal-dreams(src/components/recipe-scroller/RecipeStepProgress.tsx). The source took an externally-suppliedactiveIdplus ascrollContainerRefand rendered numbered circles. The library version trades the parent-driven cursor for an internalIntersectionObserver(with a controlled escape hatch), adds arailvariant, supports both orientations, and repaints in--cb-*tokens.