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.
// 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 }Installation
$ pnpm dlx shadcn@latest add @ballmac/features-5Install the dependencies.
$ pnpm add lucide-react motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/avatar @ballmac/badge @ballmac/mediaCopy the source into your project.
components/ballmac/blocks/features-5/features-5.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 }Update the import paths to match your project setup.
Usage
import { Features5 } from "@/components/ballmac/blocks/features-5/features-5"
export default function Features5Demo() {
return <Features5 />
}Dependencies
Use with AI
A how-it-works section that shows the product as you read. Pass steps=[{ title, description, points?, visual }] where visual is any ReactNode. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Features 5: scrolling product tour (@ballmac/features-5) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Explaining a workflow in three to five steps
- Products where each step has a distinct screen
Not for
- Long feature lists (use features-2)
- Pages without room to scroll
Registry JSON: https://ui.ballmac.com/r/features-5.json
Free to use in personal and commercial projects.
More features blocks
Features 1: bento grid
Bento feature grid of spotlight cards in two sizes, with an animated stat and keyboard-shortcut hints mixed in for texture.
Features 2: numbered icon grid
Six features in a hairline grid of numbered cells with an icon, title and one sentence each. Pass your own features array.
Features 3: integrations hub with beams
Integrations section: heading, description and checked benefits beside a hub diagram where animated beams flow between your product and six tools.
Features 4: Mac app bento
Bento feature grid for a desktop app: menu bar companion, keyboard shortcuts, privacy, iCloud sync and notifications, each with a small live visual.