Tour 1: guided product tour
A guided product tour: a spotlight lifts one part of your interface out of a dimmed page while a card says what it is for, with Next, Back, arrow keys, Escape, a progress bar, focus that follows the step, and a restart.
// Ballmac UI: Tour 1. https://ui.ballmac.com/blocks/tour-1
"use client"
import * as React from "react"
import { ArrowLeft, ArrowRight, Bell, BarChart3, FolderKanban, Home, Inbox, Play, RotateCcw, Search, Settings, X } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { cn } from "@/lib/utils"
type Tour1Step = {
/** The value of the data-tour attribute on the element to point at. */
target: string
title: string
body: string
}
type Tour1Props = Omit<React.ComponentProps<"section">, "title" | "onChange"> & {
steps?: Tour1Step[]
/** The interface to tour. Mark the elements to point at with data-tour="name". Leave out to tour a built-in sample app. */
children?: React.ReactNode
/** Start the tour already running, at a step. */
defaultStep?: number | null
/** Called when the tour ends, finished or skipped. */
onFinish?: (result: { completed: boolean; step: number }) => void
}
const sampleSteps: Tour1Step[] = [
{ target: "nav", title: "Everything starts here", body: "Move between your home, projects, inbox and reports. The one you are on is highlighted." },
{ target: "search", title: "Find anything fast", body: "Press / from anywhere to search projects, people and files. It finds words inside documents too." },
{ target: "chart", title: "See how the week went", body: "This chart shows work finished each day. Hover or focus a bar to read its exact number." },
{ target: "alerts", title: "Never miss a mention", body: "Replies and approvals land here. A dot means something is waiting for you." },
]
function SampleApp() {
const bars = [38, 52, 44, 68, 59, 31, 74]
return (
<div className="bg-card grid min-h-[36rem] grid-cols-[3.5rem_minmax(0,1fr)] sm:grid-cols-[11rem_minmax(0,1fr)]">
<nav data-tour="nav" aria-label="Sample navigation" className="bg-muted/40 border-e p-2 sm:p-3">
<p className="mb-3 hidden px-2 text-sm font-semibold sm:block">Acme</p>
{[[Home, "Home"], [FolderKanban, "Projects"], [Inbox, "Inbox"], [BarChart3, "Reports"], [Settings, "Settings"]].map(([Icon, label], i) => {
const I = Icon as typeof Home
return (
<div key={label as string} className={cn("flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", i === 0 ? "bg-background font-medium shadow-sm" : "text-muted-foreground")}>
<I aria-hidden="true" className="size-4 shrink-0" />
<span className="hidden sm:inline">{label as string}</span>
</div>
)
})}
</nav>
<div className="min-w-0 p-3 sm:p-5">
<div className="flex items-center gap-3">
<div data-tour="search" className="bg-background text-muted-foreground flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border px-3 text-sm">
<Search aria-hidden="true" className="size-4 shrink-0" />
<span className="truncate">Search projects, people, files</span>
</div>
<div data-tour="alerts" className="bg-background relative flex size-9 shrink-0 items-center justify-center rounded-lg border">
<Bell aria-hidden="true" className="size-4" />
<span aria-hidden="true" className="bg-destructive absolute end-1.5 top-1.5 size-2 rounded-full" />
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3">
{[["Open", "24"], ["Done", "118"], ["Late", "3"]].map(([l, v]) => (
<div key={l} className="rounded-xl border p-3">
<p className="text-muted-foreground text-xs">{l}</p>
<p className="text-xl font-semibold tabular-nums">{v}</p>
</div>
))}
</div>
<div data-tour="chart" className="mt-4 rounded-xl border p-4">
<p className="text-sm font-medium">Finished this week</p>
<div className="mt-3 flex h-28 items-end gap-2" aria-hidden="true">
{bars.map((h, i) => (
<span key={i} className="bg-chart-1/70 flex-1 rounded-t-md" style={{ height: `${h}%` }} />
))}
</div>
</div>
</div>
</div>
)
}
/**
* A guided product tour: a spotlight lifts one part of your interface out of a dimmed page while a card beside it says what it is for. Next, Back, the arrow
* keys and Escape all work, a progress bar and 'Step 2 of 4' say where you are, focus moves to the card at each step, and the tour can be started again. Mark what to
* point at with data-tour attributes in your own interface, or leave it empty to tour the sample app.
*/
function Tour1({ steps = sampleSteps, children, defaultStep = null, onFinish, className, ...props }: Tour1Props) {
const id = React.useId()
const [step, setStep] = React.useState<number | null>(defaultStep)
const [rect, setRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [box, setBox] = React.useState({ w: 0, h: 0 })
const stage = React.useRef<HTMLDivElement>(null)
const card = React.useRef<HTMLDivElement>(null)
const startBtn = React.useRef<HTMLButtonElement>(null)
const running = step !== null
const s = running ? steps[step!] : undefined
const measure = React.useCallback(() => {
const root = stage.current
if (!root || step === null) return
const el = root.querySelector<HTMLElement>(`[data-tour="${steps[step]?.target}"]`)
const r0 = root.getBoundingClientRect()
setBox({ w: r0.width, h: r0.height })
if (!el) return setRect(null)
const r = el.getBoundingClientRect()
setRect({ x: r.left - r0.left, y: r.top - r0.top, w: r.width, h: r.height })
}, [step, steps])
React.useLayoutEffect(() => {
measure()
if (!running) return
window.addEventListener("resize", measure)
const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null
if (stage.current) ro?.observe(stage.current)
return () => {
window.removeEventListener("resize", measure)
ro?.disconnect()
}
}, [measure, running])
// Move focus to the card when a step appears, and back to Start when the tour ends.
const wasRunning = React.useRef(running)
const mounted = React.useRef(false)
React.useEffect(() => {
// Never take focus on page load, only when the person starts, moves or ends the tour.
if (mounted.current) {
if (running) card.current?.querySelector<HTMLElement>("[data-primary]")?.focus({ preventScroll: true })
else if (wasRunning.current) startBtn.current?.focus()
}
mounted.current = true
wasRunning.current = running
}, [running, step])
function end(completed: boolean) {
onFinish?.({ completed, step: step ?? 0 })
setStep(null)
}
const go = (d: number) => {
if (step === null) return
const n = step + d
if (n >= steps.length) end(true)
else if (n >= 0) setStep(n)
}
// The card sits below the target when there is room, otherwise above, and stays inside the stage.
const CARD_W = Math.min(320, Math.max(220, box.w - 24))
const pad = 8
const CARD_H = 215
const pos = (() => {
if (!rect) return { left: 12, top: 12 }
const below = rect.y + rect.h + pad + 6 + CARD_H < box.h
const left = Math.max(12, Math.min(box.w - CARD_W - 12, rect.x + rect.w / 2 - CARD_W / 2))
return { left, top: Math.max(12, Math.min(box.h - CARD_H - 12, below ? rect.y + rect.h + pad + 6 : rect.y - pad - CARD_H - 6)) }
})()
return (
<section data-slot="tour-1" aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-4xl px-4 py-10 sm:px-6", className)} {...props}>
<div className="mb-6 flex flex-wrap items-end justify-between gap-4">
<div>
<h2 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance">
Take the tour
</h2>
<p className="text-muted-foreground mt-2 max-w-md text-pretty">A minute to see where things are.</p>
</div>
<Button ref={startBtn} onClick={() => setStep(0)} disabled={running}>
{running ? <RotateCcw aria-hidden="true" /> : <Play aria-hidden="true" className="fill-current" />}
{running ? "Tour in progress" : "Start the tour"}
</Button>
</div>
<div
ref={stage}
className="relative overflow-hidden rounded-3xl border"
onKeyDown={(e) => {
if (!running) return
if (e.key === "Escape") { e.stopPropagation(); end(false) }
else if (e.key === "ArrowRight") { e.preventDefault(); go(1) }
else if (e.key === "ArrowLeft") { e.preventDefault(); go(-1) }
}}
>
<div inert={running ? true : undefined}>{children ?? <SampleApp />}</div>
{running && s ? (
<>
{!rect ? <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-black/55" /> : null}
{rect ? (
<div aria-hidden="true" className="ring-background pointer-events-none absolute rounded-xl ring-2 transition-[left,top,width,height] duration-300 ease-out motion-reduce:transition-none" style={{ left: rect.x - 6, top: rect.y - 6, width: rect.w + 12, height: rect.h + 12, boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.55)" }} />
) : null}
<div
ref={card}
role="dialog"
aria-modal="false"
aria-labelledby={`${id}-st`}
aria-describedby={`${id}-sb`}
className="bg-popover text-popover-foreground absolute z-10 rounded-2xl border p-5 shadow-2xl transition-[left,top] duration-300 ease-out motion-reduce:transition-none"
style={{ width: CARD_W, left: pos.left, top: pos.top }}
>
<div className="flex items-start justify-between gap-3">
<p className="text-muted-foreground text-xs font-medium tabular-nums">
Step {step! + 1} of {steps.length}
</p>
<button type="button" onClick={() => end(false)} aria-label="Skip the tour" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 rounded-md p-1 outline-none focus-visible:ring-[3px]">
<X aria-hidden="true" className="size-4" />
</button>
</div>
<h3 id={`${id}-st`} className="mt-1.5 font-semibold tracking-tight text-balance">
{s.title}
</h3>
<p id={`${id}-sb`} className="text-muted-foreground mt-1.5 text-sm text-pretty">
{s.body}
</p>
<div role="progressbar" aria-label="Tour progress" aria-valuemin={1} aria-valuemax={steps.length} aria-valuenow={step! + 1} className="mt-4 flex gap-1">
{steps.map((_, i) => (
<span key={i} aria-hidden="true" className={cn("h-1 flex-1 rounded-full", i <= step! ? "bg-foreground" : "bg-muted")} />
))}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onClick={() => go(-1)} disabled={step === 0}>
<ArrowLeft aria-hidden="true" className="rtl:rotate-180" />
Back
</Button>
<Button size="sm" data-primary onClick={() => go(1)}>
{step === steps.length - 1 ? "Finish" : "Next"}
{step === steps.length - 1 ? null : <ArrowRight aria-hidden="true" className="rtl:rotate-180" />}
</Button>
</div>
</div>
</>
) : null}
</div>
<p className="text-muted-foreground mt-3 text-xs">While the tour runs: Left and Right arrows move between steps, Escape ends it.</p>
<p aria-live="polite" className="sr-only">
{running && s ? `Step ${step! + 1} of ${steps.length}: ${s.title}` : ""}
</p>
</section>
)
}
export { Tour1, type Tour1Props, type Tour1Step }Installation
$ pnpm dlx shadcn@latest add @ballmac/tour-1Install the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/buttonCopy the source into your project.
components/ballmac/blocks/tour-1/tour-1.tsx// Ballmac UI: Tour 1. https://ui.ballmac.com/blocks/tour-1 "use client" import * as React from "react" import { ArrowLeft, ArrowRight, Bell, BarChart3, FolderKanban, Home, Inbox, Play, RotateCcw, Search, Settings, X } from "lucide-react" import { Button } from "@/components/ballmac/button" import { cn } from "@/lib/utils" type Tour1Step = { /** The value of the data-tour attribute on the element to point at. */ target: string title: string body: string } type Tour1Props = Omit<React.ComponentProps<"section">, "title" | "onChange"> & { steps?: Tour1Step[] /** The interface to tour. Mark the elements to point at with data-tour="name". Leave out to tour a built-in sample app. */ children?: React.ReactNode /** Start the tour already running, at a step. */ defaultStep?: number | null /** Called when the tour ends, finished or skipped. */ onFinish?: (result: { completed: boolean; step: number }) => void } const sampleSteps: Tour1Step[] = [ { target: "nav", title: "Everything starts here", body: "Move between your home, projects, inbox and reports. The one you are on is highlighted." }, { target: "search", title: "Find anything fast", body: "Press / from anywhere to search projects, people and files. It finds words inside documents too." }, { target: "chart", title: "See how the week went", body: "This chart shows work finished each day. Hover or focus a bar to read its exact number." }, { target: "alerts", title: "Never miss a mention", body: "Replies and approvals land here. A dot means something is waiting for you." }, ] function SampleApp() { const bars = [38, 52, 44, 68, 59, 31, 74] return ( <div className="bg-card grid min-h-[36rem] grid-cols-[3.5rem_minmax(0,1fr)] sm:grid-cols-[11rem_minmax(0,1fr)]"> <nav data-tour="nav" aria-label="Sample navigation" className="bg-muted/40 border-e p-2 sm:p-3"> <p className="mb-3 hidden px-2 text-sm font-semibold sm:block">Acme</p> {[[Home, "Home"], [FolderKanban, "Projects"], [Inbox, "Inbox"], [BarChart3, "Reports"], [Settings, "Settings"]].map(([Icon, label], i) => { const I = Icon as typeof Home return ( <div key={label as string} className={cn("flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm", i === 0 ? "bg-background font-medium shadow-sm" : "text-muted-foreground")}> <I aria-hidden="true" className="size-4 shrink-0" /> <span className="hidden sm:inline">{label as string}</span> </div> ) })} </nav> <div className="min-w-0 p-3 sm:p-5"> <div className="flex items-center gap-3"> <div data-tour="search" className="bg-background text-muted-foreground flex h-9 min-w-0 flex-1 items-center gap-2 rounded-lg border px-3 text-sm"> <Search aria-hidden="true" className="size-4 shrink-0" /> <span className="truncate">Search projects, people, files</span> </div> <div data-tour="alerts" className="bg-background relative flex size-9 shrink-0 items-center justify-center rounded-lg border"> <Bell aria-hidden="true" className="size-4" /> <span aria-hidden="true" className="bg-destructive absolute end-1.5 top-1.5 size-2 rounded-full" /> </div> </div> <div className="mt-4 grid grid-cols-3 gap-3"> {[["Open", "24"], ["Done", "118"], ["Late", "3"]].map(([l, v]) => ( <div key={l} className="rounded-xl border p-3"> <p className="text-muted-foreground text-xs">{l}</p> <p className="text-xl font-semibold tabular-nums">{v}</p> </div> ))} </div> <div data-tour="chart" className="mt-4 rounded-xl border p-4"> <p className="text-sm font-medium">Finished this week</p> <div className="mt-3 flex h-28 items-end gap-2" aria-hidden="true"> {bars.map((h, i) => ( <span key={i} className="bg-chart-1/70 flex-1 rounded-t-md" style={{ height: `${h}%` }} /> ))} </div> </div> </div> </div> ) } /** * A guided product tour: a spotlight lifts one part of your interface out of a dimmed page while a card beside it says what it is for. Next, Back, the arrow * keys and Escape all work, a progress bar and 'Step 2 of 4' say where you are, focus moves to the card at each step, and the tour can be started again. Mark what to * point at with data-tour attributes in your own interface, or leave it empty to tour the sample app. */ function Tour1({ steps = sampleSteps, children, defaultStep = null, onFinish, className, ...props }: Tour1Props) { const id = React.useId() const [step, setStep] = React.useState<number | null>(defaultStep) const [rect, setRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(null) const [box, setBox] = React.useState({ w: 0, h: 0 }) const stage = React.useRef<HTMLDivElement>(null) const card = React.useRef<HTMLDivElement>(null) const startBtn = React.useRef<HTMLButtonElement>(null) const running = step !== null const s = running ? steps[step!] : undefined const measure = React.useCallback(() => { const root = stage.current if (!root || step === null) return const el = root.querySelector<HTMLElement>(`[data-tour="${steps[step]?.target}"]`) const r0 = root.getBoundingClientRect() setBox({ w: r0.width, h: r0.height }) if (!el) return setRect(null) const r = el.getBoundingClientRect() setRect({ x: r.left - r0.left, y: r.top - r0.top, w: r.width, h: r.height }) }, [step, steps]) React.useLayoutEffect(() => { measure() if (!running) return window.addEventListener("resize", measure) const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null if (stage.current) ro?.observe(stage.current) return () => { window.removeEventListener("resize", measure) ro?.disconnect() } }, [measure, running]) // Move focus to the card when a step appears, and back to Start when the tour ends. const wasRunning = React.useRef(running) const mounted = React.useRef(false) React.useEffect(() => { // Never take focus on page load, only when the person starts, moves or ends the tour. if (mounted.current) { if (running) card.current?.querySelector<HTMLElement>("[data-primary]")?.focus({ preventScroll: true }) else if (wasRunning.current) startBtn.current?.focus() } mounted.current = true wasRunning.current = running }, [running, step]) function end(completed: boolean) { onFinish?.({ completed, step: step ?? 0 }) setStep(null) } const go = (d: number) => { if (step === null) return const n = step + d if (n >= steps.length) end(true) else if (n >= 0) setStep(n) } // The card sits below the target when there is room, otherwise above, and stays inside the stage. const CARD_W = Math.min(320, Math.max(220, box.w - 24)) const pad = 8 const CARD_H = 215 const pos = (() => { if (!rect) return { left: 12, top: 12 } const below = rect.y + rect.h + pad + 6 + CARD_H < box.h const left = Math.max(12, Math.min(box.w - CARD_W - 12, rect.x + rect.w / 2 - CARD_W / 2)) return { left, top: Math.max(12, Math.min(box.h - CARD_H - 12, below ? rect.y + rect.h + pad + 6 : rect.y - pad - CARD_H - 6)) } })() return ( <section data-slot="tour-1" aria-labelledby={`${id}-title`} className={cn("mx-auto w-full max-w-4xl px-4 py-10 sm:px-6", className)} {...props}> <div className="mb-6 flex flex-wrap items-end justify-between gap-4"> <div> <h2 id={`${id}-title`} className="text-3xl font-semibold tracking-[-0.035em] text-balance"> Take the tour </h2> <p className="text-muted-foreground mt-2 max-w-md text-pretty">A minute to see where things are.</p> </div> <Button ref={startBtn} onClick={() => setStep(0)} disabled={running}> {running ? <RotateCcw aria-hidden="true" /> : <Play aria-hidden="true" className="fill-current" />} {running ? "Tour in progress" : "Start the tour"} </Button> </div> <div ref={stage} className="relative overflow-hidden rounded-3xl border" onKeyDown={(e) => { if (!running) return if (e.key === "Escape") { e.stopPropagation(); end(false) } else if (e.key === "ArrowRight") { e.preventDefault(); go(1) } else if (e.key === "ArrowLeft") { e.preventDefault(); go(-1) } }} > <div inert={running ? true : undefined}>{children ?? <SampleApp />}</div> {running && s ? ( <> {!rect ? <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-black/55" /> : null} {rect ? ( <div aria-hidden="true" className="ring-background pointer-events-none absolute rounded-xl ring-2 transition-[left,top,width,height] duration-300 ease-out motion-reduce:transition-none" style={{ left: rect.x - 6, top: rect.y - 6, width: rect.w + 12, height: rect.h + 12, boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.55)" }} /> ) : null} <div ref={card} role="dialog" aria-modal="false" aria-labelledby={`${id}-st`} aria-describedby={`${id}-sb`} className="bg-popover text-popover-foreground absolute z-10 rounded-2xl border p-5 shadow-2xl transition-[left,top] duration-300 ease-out motion-reduce:transition-none" style={{ width: CARD_W, left: pos.left, top: pos.top }} > <div className="flex items-start justify-between gap-3"> <p className="text-muted-foreground text-xs font-medium tabular-nums"> Step {step! + 1} of {steps.length} </p> <button type="button" onClick={() => end(false)} aria-label="Skip the tour" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 rounded-md p-1 outline-none focus-visible:ring-[3px]"> <X aria-hidden="true" className="size-4" /> </button> </div> <h3 id={`${id}-st`} className="mt-1.5 font-semibold tracking-tight text-balance"> {s.title} </h3> <p id={`${id}-sb`} className="text-muted-foreground mt-1.5 text-sm text-pretty"> {s.body} </p> <div role="progressbar" aria-label="Tour progress" aria-valuemin={1} aria-valuemax={steps.length} aria-valuenow={step! + 1} className="mt-4 flex gap-1"> {steps.map((_, i) => ( <span key={i} aria-hidden="true" className={cn("h-1 flex-1 rounded-full", i <= step! ? "bg-foreground" : "bg-muted")} /> ))} </div> <div className="mt-4 flex items-center justify-between gap-2"> <Button variant="ghost" size="sm" onClick={() => go(-1)} disabled={step === 0}> <ArrowLeft aria-hidden="true" className="rtl:rotate-180" /> Back </Button> <Button size="sm" data-primary onClick={() => go(1)}> {step === steps.length - 1 ? "Finish" : "Next"} {step === steps.length - 1 ? null : <ArrowRight aria-hidden="true" className="rtl:rotate-180" />} </Button> </div> </div> </> ) : null} </div> <p className="text-muted-foreground mt-3 text-xs">While the tour runs: Left and Right arrows move between steps, Escape ends it.</p> <p aria-live="polite" className="sr-only"> {running && s ? `Step ${step! + 1} of ${steps.length}: ${s.title}` : ""} </p> </section> ) } export { Tour1, type Tour1Props, type Tour1Step }Update the import paths to match your project setup.
Usage
"use client"
import { Tour1 } from "@/components/ballmac/blocks/tour-1/tour-1"
export default function Tour1Demo() {
return <Tour1 />
}States
This block has no states. A guided overlay with no data of its own: nothing to load, submit or fail.
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 2 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
4 tab stops, each showing a visible change when it takes focus. 3 documented keys.
Not applicable: Loading, empty and error
A guided overlay with no data of its own: nothing to load, submit or fail.
Passed: Automated tests
Covered by tour-1.test.tsx.
For information: Footprint
4.2 KB of source (gzip) in 1 file. Installs lucide-react.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Dependencies
- npm
- lucide-react
- Registry
- @ballmac/buttonshadcn/utils
Use with AI
A product tour. <Tour1 steps={[{ target: 'search', title, body }]} defaultStep={null | 0} onFinish={({ completed, step }) => ...}>{your interface}</Tour1>. Mark the elements to point at with data-tour='search' inside the children; leave children out to tour a built-in sample app. The page behind is inert while the tour runs; the spotlight and card follow each target and the card stays inside the stage, above or below the target. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tour 1: guided product tour (@ballmac/tour-1) to this project with the shadcn MCP, then use it where it fits.
Use it for
- First-run onboarding that points at real parts of the interface
- Introducing a redesign or a new feature
Not for
- A setup checklist (use onboarding-1)
- One-off hints (use tooltip or popover)
Registry JSON: https://ui.ballmac.com/r/tour-1.json
Free to use in personal and commercial projects.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.