Showcase Phase
A horizontal coverflow carousel for picking one of several themed options at the start of a flow. Each card carries an accent color that drives its gradient, border, glow, and indicator dot; sibling cards recede via a continuous distance-from-center scale + opacity ramp. Tapping a non-centered card silently recenters it; tapping the active card fires a brief flash, then onAdvance(item).
Strictly presentational — the component owns the scroll position, the brief tap flash, and the per-card style math, but knows nothing about onboarding phases, persistence, or progression. The parent reacts to onAdvance to do the next thing.
Each pattern, alive.
Scroll or swipe, then tap what interests you
Installation
npx shadcn@latest add https://craftbits.dev/r/showcase-phase.jsonUsage
import { ShowcasePhase, type ShowcaseItem } from "@craft-bits/core";
const items: ShowcaseItem[] = [
{
id: "tide",
name: "Tide",
tagline: "Ebb-and-flow control",
hex: "#3B82F6",
renderIllustration: (size) => <TideSvg size={size} />,
},
{
id: "ember",
name: "Ember",
tagline: "Glowing accents",
hex: "#F59E0B",
renderIllustration: (size) => <EmberSvg size={size} />,
},
];
<ShowcasePhase
title="Pick a starting kit"
subtitle="Scroll or swipe, then tap what interests you"
items={items}
onAdvance={(item) => router.push("/p/" + item.id)}
/>renderIllustration is a render-prop so the consumer owns the illustration system. It receives a size in pixels (the carousel scales it with container width) so the illustration stays optically consistent with the card. Pass any node — inline SVG, a sized <Image>, a canvas, an emoji.
Compose with the top-level Onboarding shell so phase swaps are animated for free:
import { Onboarding, ShowcasePhase, type OnboardingPhase } from "@craft-bits/core";
const PHASES: OnboardingPhase[] = [
{
id: "showcase",
label: "Pick a kit",
content: (ctx) => (
<ShowcasePhase
title="Each pattern, alive."
subtitle="Scroll or swipe, then tap what interests you"
items={items}
onAdvance={() => ctx.advance()}
/>
),
},
];Understanding the component
- Distance drives style. The carousel computes the geometric distance from each card's center to the viewport center, normalized by the card-plus-gap step. That single distance value scales the card, fades it, and decides whether to paint it in the active gradient/border/glow tier.
- Off-center taps recenter. When a card's distance exceeds 0.3, tapping it calls a smooth
scrollToinstead of advancing. The user re-aims; they don't fight the carousel. Only the centered card actually advances. - Brief tap-flash before advance. Tapping the centered card sets a 300ms timer that shows a small scale-down + glow flash, then fires
onAdvance(item). The flash gives the user a "yes, this one" beat before the next phase replaces the surface. - Ambient halo follows the active card. Two pointer-events-off motion divs painted at the active card's hex sit behind the carousel — a radial halo across the top half and a softer blurred glow at center — so the surface tints itself with the choice as the user scrolls.
- Responsive sizing via ResizeObserver. Card width clamps between 224 and 280px based on container width; the illustration well clamps between 132 and 172; the side padding centers the first/last cards in narrow viewports. ResizeObserver keeps the math live without falling back to
getBoundingClientRect. - Indicator dots are decorative. The dot strip below the row reflects which card is centered but is
aria-hidden— the carousel itself carriesrole="region"plusaria-roledescription="carousel"and each card has its ownaria-label, which is the actual announcement surface for assistive tech.
Variants
The shell ships no visual variants — its job is the cover-flow rendering. Customize via:
renderIllustrationper item — paint anything inside the illustration wellfooter— optional bottom slot, typically a Skip / Help buttonemptyState— string or node shown whenitemsis empty (passnullto render nothing)className— merged on the outer container viacn()
Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | ReactNode | — | Headline above the carousel. Pass "" or null to suppress. |
subtitle | ReactNode | — | One-line subtitle under the title. |
items | readonly ShowcaseItem[] | required | Cards in display order. |
onAdvance | (item: ShowcaseItem) => void | required | Fires when the user taps the centered card after the 300ms flash. |
emptyState | ReactNode | "Loading…" | Fallback when items is empty. Pass null to render nothing. |
footer | ReactNode | — | Optional bottom slot — typically a Skip button. |
regionAriaLabel | string | "Showcase" | Accessible name for the outer region landmark. |
className | string | — | Merged onto the outer container via cn(). |
ShowcaseItem
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier. Used for keying + the onAdvance payload. |
name | ReactNode | Bold heading rendered under the illustration. |
tagline | ReactNode | Optional subtitle under the name. |
hex | string | Accent color (any valid CSS color). Paints gradient + border + glow + indicator dot. |
renderIllustration | (size: number) => ReactNode | Optional render-prop. Receives the computed pixel size for sizing SVG / canvas / image consistently. |
ariaLabel | string | Optional per-item override of the button's accessible name. Defaults to Explore {name} when name is a string. |
Accessibility
- The root is a
role="region"landmark witharia-roledescription="carousel"and a configurablearia-label(defaults toShowcase). - Each card is a
<button type="button">with anaria-label(item-provided or derived fromname) andaria-current="true"when centered. - The scroll row carries
aria-live="polite"so screen readers track which card just snapped into focus on swipe. - The decorative indicator dots are
aria-hidden="true"— they reinforce the visual state, they aren't the announcement surface. - Card cover-flow scale and opacity update via short CSS transitions so the motion respects browser-level reduced-motion at the OS layer; the framer-driven halo + glow short-circuit to no-animation when
prefers-reduced-motionis set. - Each card uses
focus-visible:ring-2 ring-cb-accentso keyboard users see which card is focused. Tab order follows DOM order — the first card is first focused, regardless of which is centered. - The footer slot is the right place for a Skip control; the shell itself doesn't render or own a Skip button.
Credits
- Extracted from:
AlgoFlashcards(src/platform/ui/onboarding/ShowcasePhase.tsx). The source baked in pattern-typed cards (PatternId/PatternIllustrationKey/demoCardKey), a project-specificSectionIllustrationcomponent, the lesson-runtimeuseTimershook, and hardcoded title + subtitle + Skip button. craft-bits' version strips all of that: cards become a plainShowcaseItem[]with arenderIllustrationrender-prop, the tap-flash timer is owned by an internal ref with cleanup-on-unmount, and the title / subtitle / footer / empty state all become slots so the shell is content-agnostic.