How It Works — Scroll Stepper
blockmarketingScroll-driven walkthrough where a sticky progress rail tracks which step is in view while the step copy scrolls past, falling back to a plain list without IntersectionObserver.
Installation
$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/how-it-works-scroll-stepper.json$npx shadcn@latest add https://uipkge.dev/r/react/how-it-works-scroll-stepper.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/how-it-works-scroll-stepper.json$bunx shadcn@latest add https://uipkge.dev/r/react/how-it-works-scroll-stepper.jsonNamed registry:
npx shadcn@latest add @uipkge-react/how-it-works-scroll-stepperInstalls to:components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
npm dependencies
Files installed (1)
components/blocks/HowItWorksScrollStepper.tsx4.7 kB
'use client' import { useEffect, useRef, useState } from 'react' import { ArrowRight } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { Progress } from '@/components/ui/progress' const steps = [ { id: 'connect', title: 'Connect the warehouse', body: 'A read-only role and the account URL. We mirror the schema and catalogue what is there — no agent, no extract, no copy of your data leaving the account.', }, { id: 'define', title: 'Define the metrics that matter', body: 'Start with the numbers that already appear in the board deck. Each becomes a versioned definition with an owner, reviewed like any other change in the repo.', }, { id: 'certify', title: 'Certify against history', body: 'Every definition is reconciled against the prior four quarters before it can be published. Mismatches surface as a diff, not as a surprise in the close.', }, { id: 'publish', title: 'Publish once, everywhere', body: 'Dashboards, scheduled exports, and the API all resolve the same definition. Changing it updates every consumer, and reverting is the same one click.', }, ] export function HowItWorksScrollStepper() { const [active, setActive] = useState(0) const root = useRef<HTMLElement | null>(null) useEffect(() => { // Without IntersectionObserver the rail simply stays on the first step and // the copy reads as a plain ordered list. if (typeof IntersectionObserver === 'undefined' || !root.current) return const targets = root.current.querySelectorAll<HTMLElement>('[data-step]') const observer = new IntersectionObserver( (entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue const index = Number((entry.target as HTMLElement).dataset.step) if (!Number.isNaN(index)) setActive(index) } }, { rootMargin: '-45% 0px -45% 0px' }, ) targets.forEach((target) => observer.observe(target)) return () => observer.disconnect() }, []) return ( <section ref={root} data-slot="how-it-works-scroll-stepper" className="bg-background"> <div className="mx-auto max-w-6xl px-6 py-20 lg:py-28"> <div className="max-w-2xl"> <Badge variant="secondary">Walkthrough</Badge> <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Scroll through the first week</h2> </div> <div className="mt-12 grid gap-10 lg:grid-cols-[18rem_1fr] lg:gap-16"> {/* Sticky rail tracks whichever step is nearest the viewport middle. */} <div className="lg:sticky lg:top-24 lg:self-start"> <Card> <CardContent className="p-5"> <p className="text-muted-foreground font-mono text-xs tracking-[0.14em] uppercase">Progress</p> <Progress value={((active + 1) / steps.length) * 100} className="mt-3 h-1.5" aria-label="Walkthrough progress" /> <ol className="mt-5 space-y-3"> {steps.map((step, index) => ( <li key={step.id} className={`flex items-start gap-3 text-sm transition-colors ${ index === active ? 'text-foreground' : 'text-muted-foreground' }`} > <span className={`mt-1 size-1.5 shrink-0 rounded-full transition-colors ${ index === active ? 'bg-primary' : 'bg-muted-foreground/40' }`} aria-hidden="true" /> <span className="font-medium">{step.title}</span> </li> ))} </ol> </CardContent> </Card> </div> <ol className="space-y-16 lg:space-y-28"> {steps.map((step, index) => ( <li key={step.id} data-step={index} className="scroll-mt-32"> <p className="text-muted-foreground font-mono text-xs">{String(index + 1).padStart(2, '0')}</p> <h3 className="mt-2 text-2xl font-semibold tracking-tight text-balance">{step.title}</h3> <p className="text-muted-foreground mt-3 max-w-prose leading-relaxed">{step.body}</p> </li> ))} </ol> </div> <div className="mt-12"> <Button> Start the walkthrough <ArrowRight className="ml-2 size-4" aria-hidden="true" /> </Button> </div> </div> </section> ) }
Raw manifest:https://uipkge.dev/r/react/how-it-works-scroll-stepper.json