# Features 5: scrolling product tour

> Numbered steps on the left; as each scrolls into view, a sticky panel on the right crossfades to its picture. On phones every step carries its own picture.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/features-5).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/features-5
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/features-5-light-f9ab0c5849db8616.png
- Registry item: https://ui.ballmac.com/r/features-5.json
- Categories: blocks, features
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/features-5.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`
- Registry items: `utils`, `https://ui.ballmac.com/r/avatar.json`, `https://ui.ballmac.com/r/badge.json`, `https://ui.ballmac.com/r/media.json`

## Files

### `registry/ballmac/components/blocks/features-5/features-5.tsx`

```tsx
// Ballmac UI: Features 5. https://ui.ballmac.com/blocks/features-5
"use client"

import * as React from "react"
import { Check, Inbox, Link2, Lock, Sparkles, Tag, Workflow, Zap } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { Avatar, AvatarFallback } from "@/components/ballmac/avatar"
import { Badge } from "@/components/ballmac/badge"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"

type Features5Step = {
  /** Step title. */
  title: string
  /** One or two sentences. */
  description: string
  /** Short checked points under the description. */
  points?: string[]
  /** The sample picture shown beside the steps while this step is in view. Decorative: describe it in the text. Replaced by `image` when you set one. */
  visual?: React.ReactNode
  /** Your own picture for this step: an image URL (give it imageAlt), an object with alt text and a dark-mode file, or your own element. */
  image?: MediaSource
  /** Describes `image` when it is a plain URL. */
  imageAlt?: string
}

type Features5Props = Omit<React.ComponentProps<"section">, "title"> & {
  /** Label above the heading. */
  eyebrow?: string
  /** Section heading. */
  title?: string
  /** One sentence under the heading. */
  description?: string
  /** The steps, in order. */
  steps?: Features5Step[]
}

/* ----------------------------------------------------------------------------------------------
 * Sample visuals: small product panels drawn with theme tokens.
 * --------------------------------------------------------------------------------------------*/

const panel = "bg-card w-full max-w-sm rounded-2xl border p-5 shadow-[0_30px_70px_-35px_rgb(0_0_0/0.4)]"

function CaptureVisual() {
  const items = [
    { t: "Pricing page feedback from Maya", tag: "Customer", tone: "bg-chart-1" },
    { t: "Ship the invoice export before Friday", tag: "Task", tone: "bg-chart-3" },
    { t: "Idea: weekly digest for admins", tag: "Idea", tone: "bg-chart-5" },
  ]
  return (
    <div className={panel}>
      <div className="flex items-center gap-2 text-sm font-medium">
        <Inbox className="size-4" /> Inbox <Badge variant="secondary" className="ms-auto">3 new</Badge>
      </div>
      <ul className="mt-4 space-y-2.5">
        {items.map((i) => (
          <li key={i.t} className="flex items-center gap-3 rounded-xl border p-3">
            <span className={cn("size-2 shrink-0 rounded-full", i.tone)} />
            <span className="min-w-0 flex-1 truncate text-sm">{i.t}</span>
            <span className="text-muted-foreground text-xs">{i.tag}</span>
          </li>
        ))}
      </ul>
      <div className="text-muted-foreground mt-4 flex items-center gap-2 rounded-xl border border-dashed px-3 py-2.5 text-sm">
        <Sparkles className="size-4" /> Paste a link or just start typing
      </div>
    </div>
  )
}

function OrganizeVisual() {
  const cols = [
    { name: "Backlog", cards: ["Export to CSV", "Dark mode polish"] },
    { name: "In progress", cards: ["Invoice export", "Team roles", "Audit log"] },
    { name: "Done", cards: ["SSO setup"] },
  ]
  return (
    <div className={cn(panel, "max-w-md")}>
      <div className="flex items-center gap-2 text-sm font-medium">
        <Tag className="size-4" /> Q4 roadmap
      </div>
      <div className="mt-4 grid grid-cols-3 gap-3">
        {cols.map((c) => (
          <div key={c.name} className="bg-muted/50 rounded-xl p-2">
            <p className="text-muted-foreground px-1 pb-2 text-[11px] font-medium">{c.name}</p>
            <div className="space-y-2">
              {c.cards.map((card) => (
                <div key={card} className="bg-card rounded-lg border p-2 text-xs leading-snug shadow-xs">{card}</div>
              ))}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

function AutomateVisual() {
  const steps = [
    { icon: Zap, label: "When", value: "An invoice is overdue 7 days" },
    { icon: Workflow, label: "Then", value: "Send a friendly reminder email" },
    { icon: Link2, label: "And", value: "Post to #finance in Slack" },
  ]
  return (
    <div className={panel}>
      <div className="flex items-center justify-between text-sm font-medium">
        Overdue invoices <Badge status="success">Active</Badge>
      </div>
      <ol className="relative mt-5 space-y-3">
        <span aria-hidden="true" className="bg-border absolute top-6 bottom-6 start-[1.1rem] w-px" />
        {steps.map(({ icon: Icon, label, value }) => (
          <li key={label} className="relative flex items-center gap-3">
            <span className="bg-background relative flex size-9 shrink-0 items-center justify-center rounded-full border"><Icon className="size-4" /></span>
            <div className="min-w-0 flex-1 rounded-xl border px-3 py-2">
              <p className="text-muted-foreground text-[11px] font-medium uppercase tracking-wide">{label}</p>
              <p className="truncate text-sm">{value}</p>
            </div>
          </li>
        ))}
      </ol>
    </div>
  )
}

function ShareVisual() {
  const people = [
    ["MK", "Maya Kim", "Can edit", "bg-chart-1/25"],
    ["JO", "Jonas Ortiz", "Can comment", "bg-chart-3/25"],
    ["AS", "Amara Singh", "Can view", "bg-chart-5/25"],
  ]
  return (
    <div className={panel}>
      <p className="text-sm font-medium">Share “Q4 roadmap”</p>
      <div className="mt-4 space-y-3">
        {people.map(([i, n, r, c]) => (
          <div key={n} className="flex items-center gap-3">
            <Avatar size="sm"><AvatarFallback className={cn("text-foreground text-[10px] font-semibold", c)}>{i}</AvatarFallback></Avatar>
            <span className="flex-1 text-sm">{n}</span>
            <span className="text-muted-foreground text-xs">{r}</span>
          </div>
        ))}
      </div>
      <div className="text-muted-foreground mt-5 flex items-center gap-2 rounded-xl border px-3 py-2.5 text-sm">
        <Lock className="size-4" /> Only people with the link
        <Check className="text-chart-2 ms-auto size-4" />
      </div>
    </div>
  )
}

const defaultSteps: Features5Step[] = [
  {
    title: "Capture anything in seconds",
    description: "Notes, links and ideas land in one inbox the moment you have them, from the web, your phone or an email forward.",
    points: ["Paste a link to unfurl it", "Forward email to your inbox address"],
    visual: <CaptureVisual />,
  },
  {
    title: "Turn it into a plan",
    description: "Drag items onto a board, set an owner and a date, and watch the roadmap assemble itself.",
    points: ["Board, list and timeline views", "Owners and due dates on everything"],
    visual: <OrganizeVisual />,
  },
  {
    title: "Automate the busywork",
    description: "Describe what should happen and when. Reminders, handoffs and status updates run on their own.",
    points: ["No code, no scripts", "Works with the tools you already use"],
    visual: <AutomateVisual />,
  },
  {
    title: "Share with exactly who needs it",
    description: "Invite teammates or send a link. Permissions are per item, and you can see who looked at what.",
    points: ["View, comment or edit", "Revoke access in one click"],
    visual: <ShareVisual />,
  },
]

function Features5({
  eyebrow = "How it works",
  title = "From a loose idea to a shipped plan.",
  description = "Four steps, one workspace. Scroll to see each one.",
  steps = defaultSteps,
  className,
  ...props
}: Features5Props) {
  const [active, setActive] = React.useState(0)
  const refs = React.useRef<(HTMLLIElement | null)[]>([])
  const reduce = useReducedMotion()

  React.useEffect(() => {
    if (typeof IntersectionObserver === "undefined") return
    const nodes = refs.current.filter((n): n is HTMLLIElement => !!n)
    // A step becomes active when it crosses the middle band of the viewport.
    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) setActive(Number((entry.target as HTMLElement).dataset.index))
        }
      },
      { rootMargin: "-45% 0px -45% 0px" }
    )
    nodes.forEach((n) => observer.observe(n))
    return () => observer.disconnect()
  }, [steps.length])

  return (
    <section data-slot="features-5" className={cn("mx-auto max-w-6xl px-4 py-20 sm:px-6 md:py-28", className)} {...props}>
      <div className="max-w-2xl">
        <p className="text-muted-foreground text-sm font-medium">{eyebrow}</p>
        <h2 className="mt-3 text-3xl font-semibold tracking-[-0.035em] text-balance sm:text-4xl lg:text-5xl">{title}</h2>
        <p className="text-muted-foreground mt-4 text-lg text-pretty">{description}</p>
      </div>

      <div className="mt-14 grid gap-x-16 lg:mt-20 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.05fr)]">
        <ol className="min-w-0 space-y-14 lg:space-y-0">
          {steps.map((step, i) => (
            <li
              key={step.title}
              ref={(n) => {
                refs.current[i] = n
              }}
              data-index={i}
              data-active={active === i}
              className="group/step relative lg:flex lg:min-h-[26rem] lg:items-center"
            >
              <div className="lg:border-s lg:ps-10">
                <span
                  aria-hidden="true"
                  className="bg-foreground absolute top-[18%] bottom-[18%] start-0 hidden w-0.5 origin-top scale-y-0 rounded-full transition-transform duration-500 ease-out group-data-[active=true]/step:scale-y-100 motion-reduce:transition-none lg:block"
                />
                <p className="text-muted-foreground font-mono text-sm tabular-nums">{String(i + 1).padStart(2, "0")}</p>
                <h3 className="mt-2 text-2xl font-semibold tracking-[-0.025em] text-balance lg:text-3xl lg:text-muted-foreground lg:transition-colors lg:duration-300 lg:group-data-[active=true]/step:text-foreground">
                  {step.title}
                </h3>
                <p className="text-muted-foreground mt-3 max-w-md text-base leading-relaxed text-pretty">{step.description}</p>
                {step.points && (
                  <ul className="mt-5 space-y-2 text-sm">
                    {step.points.map((p) => (
                      <li key={p} className="flex items-start gap-2.5">
                        <Check className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
                        {p}
                      </li>
                    ))}
                  </ul>
                )}
              </div>
              {/* Phones and tablets: the picture sits right under its step. */}
              <div aria-hidden={step.image ? undefined : true} className="bg-muted/40 mt-8 flex justify-center rounded-3xl border p-5 sm:p-8 lg:hidden">{step.image ? <Media media={step.image} alt={step.imageAlt} fit="contain" className="w-full rounded-xl" /> : step.visual}</div>
            </li>
          ))}
        </ol>

        <div className="hidden lg:block" aria-hidden={steps[active]?.image ? undefined : true}>
          <div className="sticky top-24 flex h-[28rem] items-center justify-center overflow-hidden rounded-3xl border bg-muted/40 p-10">
            <div className="absolute inset-0 bg-[radial-gradient(color-mix(in_oklch,var(--foreground)_14%,transparent)_1px,transparent_1px)] [background-size:18px_18px] [mask-image:radial-gradient(70%_70%_at_50%_50%,black,transparent)]" />
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={active}
                className="relative flex w-full justify-center"
                initial={reduce ? false : { opacity: 0, y: 14, scale: 0.97, filter: "blur(6px)" }}
                animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
                exit={reduce ? undefined : { opacity: 0, y: -10, scale: 0.98, filter: "blur(6px)" }}
                transition={{ duration: 0.32, ease: [0.22, 1, 0.36, 1] }}
              >
                {steps[active]?.image ? <Media media={steps[active].image} alt={steps[active].imageAlt} fit="contain" className="w-full rounded-xl" /> : steps[active]?.visual}
              </motion.div>
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  )
}

export { Features5, type Features5Props, type Features5Step }
```
