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

Customize
Slots

Installation

npx shadcn@latest add https://craftbits.dev/r/showcase-phase.json

Usage

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

  1. 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.
  2. Off-center taps recenter. When a card's distance exceeds 0.3, tapping it calls a smooth scrollTo instead of advancing. The user re-aims; they don't fight the carousel. Only the centered card actually advances.
  3. 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.
  4. 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.
  5. 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.
  6. Indicator dots are decorative. The dot strip below the row reflects which card is centered but is aria-hidden — the carousel itself carries role="region" plus aria-roledescription="carousel" and each card has its own aria-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:

  • renderIllustration per item — paint anything inside the illustration well
  • footer — optional bottom slot, typically a Skip / Help button
  • emptyState — string or node shown when items is empty (pass null to render nothing)
  • className — merged on the outer container via cn()

Props

PropTypeDefaultDescription
titleReactNodeHeadline above the carousel. Pass "" or null to suppress.
subtitleReactNodeOne-line subtitle under the title.
itemsreadonly ShowcaseItem[]requiredCards in display order.
onAdvance(item: ShowcaseItem) => voidrequiredFires when the user taps the centered card after the 300ms flash.
emptyStateReactNode"Loading…"Fallback when items is empty. Pass null to render nothing.
footerReactNodeOptional bottom slot — typically a Skip button.
regionAriaLabelstring"Showcase"Accessible name for the outer region landmark.
classNamestringMerged onto the outer container via cn().

ShowcaseItem

FieldTypeDescription
idstringStable identifier. Used for keying + the onAdvance payload.
nameReactNodeBold heading rendered under the illustration.
taglineReactNodeOptional subtitle under the name.
hexstringAccent color (any valid CSS color). Paints gradient + border + glow + indicator dot.
renderIllustration(size: number) => ReactNodeOptional render-prop. Receives the computed pixel size for sizing SVG / canvas / image consistently.
ariaLabelstringOptional 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 with aria-roledescription="carousel" and a configurable aria-label (defaults to Showcase).
  • Each card is a <button type="button"> with an aria-label (item-provided or derived from name) and aria-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-motion is set.
  • Each card uses focus-visible:ring-2 ring-cb-accent so 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-specific SectionIllustration component, the lesson-runtime useTimers hook, and hardcoded title + subtitle + Skip button. craft-bits' version strips all of that: cards become a plain ShowcaseItem[] with a renderIllustration render-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.