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 uipkge-ng@latest add how-it-works-scroll-stepper$npx uipkge-ng@latest add how-it-works-scroll-stepper$yarn dlx uipkge-ng@latest add how-it-works-scroll-stepper$bunx uipkge-ng@latest add how-it-works-scroll-stepperManifest:
https://uipkge.dev/r/angular/how-it-works-scroll-stepper.jsonInstalls to:src/app/components/blocks/how-it-works-scroll-stepper/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ScrollStepperStepinterface ScrollStepperStep {
id: string
title: string
body: string
}npm dependencies
Files installed (2)
src/app/components/blocks/how-it-works-scroll-stepper/how-it-works-scroll-stepper.component.ts6.3 kB
// Scroll-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. // Port of the React block 1:1. import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, computed, inject, signal, } from '@angular/core' import { ArrowRight, LucideAngularModule } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent } from '@/ui/card/card.component' import { UiProgressComponent } from '@/ui/progress/progress.component' export interface ScrollStepperStep { id: string title: string body: string } const steps: ScrollStepperStep[] = [ { 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.', }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-how-it-works-scroll-stepper, [ui-how-it-works-scroll-stepper]', standalone: true, // React renders the root <section> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiProgressComponent, ], template: ` <section data-slot="how-it-works-scroll-stepper" [class]="rootClass"> <div class="mx-auto max-w-6xl px-6 py-20 lg:py-28"> <div class="max-w-2xl"> <span ui-badge variant="secondary">Walkthrough</span> <h2 class="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">Scroll through the first week</h2> </div> <div class="mt-12 grid gap-10 lg:grid-cols-[18rem_1fr] lg:gap-16"> <!-- Sticky rail tracks whichever step is nearest the viewport middle. --> <div class="lg:sticky lg:top-24 lg:self-start"> <div ui-card> <div ui-card-content class="p-5"> <p class="text-muted-foreground font-mono text-xs tracking-[0.14em] uppercase">Progress</p> <ui-progress [value]="progress()" class="mt-3 h-1.5" aria-label="Walkthrough progress" /> <ol class="mt-5 space-y-3"> @for (step of allSteps; track step.id; let index = $index) { <li [class]="railItemClass(index)"> <span [class]="railDotClass(index)" aria-hidden="true"></span> <span class="font-medium">{{ step.title }}</span> </li> } </ol> </div> </div> </div> <ol class="space-y-16 lg:space-y-28"> @for (step of allSteps; track step.id; let index = $index) { <li [attr.data-step]="index" class="scroll-mt-32"> <p class="text-muted-foreground font-mono text-xs">{{ pad(index + 1) }}</p> <h3 class="mt-2 text-2xl font-semibold tracking-tight text-balance">{{ step.title }}</h3> <p class="text-muted-foreground mt-3 max-w-prose leading-relaxed">{{ step.body }}</p> </li> } </ol> </div> <div class="mt-12"> <button ui-button> Start the walkthrough <lucide-icon [img]="ArrowRight" class="ml-2 size-4" aria-hidden="true" /> </button> </div> </div> </section> `, }) export class UiHowItWorksScrollStepperComponent implements AfterViewInit, OnDestroy { protected readonly ArrowRight = ArrowRight protected readonly allSteps = steps private readonly host = inject(ElementRef<HTMLElement>) private observer?: IntersectionObserver @Input() set initialActive(v: number) { if (Number.isInteger(v) && v >= 0 && v < steps.length) this.active.set(v) } @Input('class') className?: string readonly active = signal(0) readonly progress = computed(() => ((this.active() + 1) / steps.length) * 100) ngAfterViewInit(): void { // Without IntersectionObserver the rail simply stays on the first step and // the copy reads as a plain ordered list. if (typeof IntersectionObserver === 'undefined') return const targets = this.host.nativeElement.querySelectorAll('[data-step]') if (targets.length === 0) return this.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)) this.active.set(index) } }, { rootMargin: '-45% 0px -45% 0px' }, ) targets.forEach((target: Element) => this.observer?.observe(target)) } ngOnDestroy(): void { this.observer?.disconnect() } get rootClass(): string { return cn('bg-background', this.className) } pad(n: number): string { return String(n).padStart(2, '0') } railItemClass(index: number): string { return cn( 'flex items-start gap-3 text-sm transition-colors', index === this.active() ? 'text-foreground' : 'text-muted-foreground', ) } railDotClass(index: number): string { return cn( 'mt-1 size-1.5 shrink-0 rounded-full transition-colors', index === this.active() ? 'bg-primary' : 'bg-muted-foreground/40', ) } setActive(index: number): void { if (index >= 0 && index < steps.length) this.active.set(index) } }src/app/components/blocks/how-it-works-scroll-stepper/index.ts0.1 kB
export { UiHowItWorksScrollStepperComponent, type ScrollStepperStep } from './how-it-works-scroll-stepper.component'
Raw manifest:https://uipkge.dev/r/angular/how-it-works-scroll-stepper.json