Menu

Change language

GitHub repo

How It Works — Scroll Stepper

blockmarketing

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.

Installation

$npx uipkge-ng@latest add how-it-works-scroll-stepper
Manifest: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…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

ScrollStepperStep
interface ScrollStepperStep {
  id: string
  title: string
  body: string
}

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

Raw manifest:https://uipkge.dev/r/angular/how-it-works-scroll-stepper.json