How it works

Three concrete steps on a spine that draws itself in view, each with a mono numeral and a tiny product photo.

blockspineanyeducation
$npx shadcn@latest add @storekit/how-it-works
how-it-works

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/how-it-works
$pnpm dlx shadcn@latest add @storekit/how-it-works
$yarn dlx shadcn@latest add @storekit/how-it-works
$bunx --bun shadcn@latest add @storekit/how-it-works

Source

components/storekit/marketing/how-it-works.tsx
87 lines
"use client"

import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"

/* Three steps strung on a spine that draws itself in view: vertical on
   mobile, horizontal on desktop. Each step pairs a mono numeral and a
   concrete sentence with a tiny product photo, so it reads as this store's
   process rather than a generic 1-2-3. */

type Step = { title: string; body: string; meta: string; image: string }

const SAMPLE: Step[] = [
  { title: "Pick", body: "Choose your essentials and how often they ship. Checkout takes under a minute.", meta: "2 min", image: "/brands/morrow/greens.webp" },
  { title: "Tested", body: "Every batch is third-party tested before it leaves our US facility.", meta: "Every batch", image: "/brands/morrow/electrolytes.webp" },
  { title: "Shipped", body: "Packed and handed to the carrier within 24 hours. Refills arrive on schedule.", meta: "24 hrs", image: "/brands/morrow/magnesium.webp" },
]

const EASE: [number, number, number, number] = [0.16, 1, 0.3, 1]

export function HowItWorks({
  eyebrow = "How it works",
  title = "From order to doorstep in three moves.",
  steps = SAMPLE,
  className,
}: {
  eyebrow?: string
  title?: string
  steps?: Step[]
  className?: string
}) {
  const reduce = useReducedMotion()
  // One in-view trigger drives the spine and the steps so they stay in sync.
  // The spine is a scaled div (transform only), not an SVG stroke, so it draws
  // as one solid line at any container size.
  const draw = { duration: reduce ? 0 : 0.9, ease: EASE }
  const spineY = { hidden: { scaleY: reduce ? 1 : 0 }, show: { scaleY: 1, transition: draw } }
  const spineX = { hidden: { scaleX: reduce ? 1 : 0 }, show: { scaleX: 1, transition: draw } }
  const step = {
    hidden: { opacity: 0, y: reduce ? 0 : 8 },
    show: { opacity: 1, y: 0, transition: { duration: 0.5, ease: EASE } },
  }
  return (
    <section data-sk-section="marketing" data-sk-variant="how-it-works" className={cn("w-full py-16 sm:py-24", className)}>
      <div className="mx-auto max-w-6xl px-4 sm:px-8">
        <p className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{eyebrow}</p>
        <h2 className="mt-3 max-w-xl font-sk-display text-3xl font-semibold leading-[1.1] tracking-[-0.02em] sm:text-4xl">{title}</h2>

        <motion.ol
          className="relative mt-12 grid gap-10 pl-8 md:grid-cols-3 md:gap-8 md:pl-0 md:pt-10"
          initial="hidden"
          whileInView="show"
          viewport={{ once: true, amount: 0.2 }}
          variants={{ hidden: {}, show: { transition: { staggerChildren: reduce ? 0 : 0.06, delayChildren: reduce ? 0 : 0.15 } } }}
        >
          {/* Vertical spine (mobile) */}
          <span aria-hidden className="absolute bottom-2 left-[5px] top-2 w-0.5 bg-sk-line md:hidden">
            <motion.span variants={spineY} className="absolute inset-0 origin-top bg-sk-accent" />
          </span>
          {/* Horizontal spine (desktop) */}
          <span aria-hidden className="absolute left-0 right-0 top-[5px] hidden h-0.5 bg-sk-line md:block">
            <motion.span variants={spineX} className="absolute inset-0 origin-left bg-sk-accent" />
          </span>

          {steps.map((s, i) => (
            <motion.li key={s.title} className="relative" variants={step}>
              <span aria-hidden className="absolute -left-8 top-1 h-3 w-3 rounded-full border-2 border-sk-accent bg-sk-paper md:-top-10 md:left-0" />
              <div className="flex items-start gap-4">
                <ProductImage src={s.image} alt="" fit="cover" className="h-16 w-16 shrink-0 rounded-sk-card border border-sk-line" zoomOnHover={false} />
                <div className="min-w-0">
                  <p className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
                    <span className="font-sk-mono text-[11px] tabular-nums tracking-[0.14em] text-sk-accent">{String(i + 1).padStart(2, "0")}</span>
                    <span className="text-[15px] font-semibold text-sk-ink">{s.title}</span>
                    <span className="font-sk-mono text-[11px] uppercase tracking-[0.14em] text-sk-muted">{s.meta}</span>
                  </p>
                  <p className="mt-1.5 max-w-xs text-sm leading-[1.5] text-sk-muted">{s.body}</p>
                </div>
              </div>
            </motion.li>
          ))}
        </motion.ol>
      </div>
    </section>
  )
}

Usage

Import from "@/components/storekit/marketing/how-it-works" and pass your product data. The demo above exercises the full prop surface with sample values.

How it works, StoreKit