Personalize Phase

A full-screen onboarding phase that lets the user personalise the product up-front with one or more option pickers — target company, plan length, theme, role — followed by a single advance CTA. The phase carries no state of its own: every section's value / onChange is owned by the caller, so it slots into any onboarding state machine.

Preview

Set your path

Build a plan that fits your week.

Pick a target company if it helps, then choose a pace you can actually keep. The goal is momentum, not another abandoned plan.

Where are you headed?

Optional, but useful when you want the goal to stay visible.

How much time do you have?

Choose the tempo you can sustain on a real calendar.

Customize
Sections
Behaviour

Installation

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

Usage

import {
  PersonalizePhase,
  type PersonalizePhaseSection,
} from "@craft-bits/core";
 
const sections: ReadonlyArray<PersonalizePhaseSection> = [
  {
    id: "company",
    title: "Where are you headed?",
    subtitle: "Optional, but useful when you want the goal to stay visible.",
    options: COMPANIES,
    value: company,
    onChange: setCompany,
    layout: "grid",
    clearable: true,
    gridCols: 3,
  },
  {
    id: "timeline",
    title: "How much time do you have?",
    options: TIMELINES,
    value: weeks,
    onChange: setWeeks,
    layout: "list",
  },
];
 
<PersonalizePhase
  eyebrow="Set your path"
  title="Build a plan that fits your week."
  subtitle="Pick a target company if it helps, then choose a pace you can actually keep."
  sections={sections}
  advanceLabel="See your path"
  advanceDisabled={weeks === null}
  onAdvance={() => router.push("/onboarding/showcase")}
/>

Single-picker variant — only show the advance once the picker resolves:

<PersonalizePhase
  title="Pick a theme to start with."
  sections={[
    {
      id: "theme",
      title: "Theme",
      options: THEMES,
      value: theme,
      onChange: setTheme,
      layout: "grid",
      gridCols: 4,
    },
  ]}
  advanceDisabled={theme === null}
  onAdvance={advance}
/>

Understanding the component

  1. Header band. An optional accent-coloured eyebrow, a balanced title, and a pretty-wrapped subtitle. Centres the lesson before the pickers appear.
  2. Sections grid. One column under lg, two columns from lg upwards when more than one section is supplied. Each section renders in its own card with a heading + optional subtitle + the picker body.
  3. Layouts. A "grid" section produces an icon-tile grid keyed off gridCols (2 / 3 / 4). A "list" section produces a vertical stack of row buttons — better for plans, tiers, or anything with a long secondary description.
  4. Selection semantics. Each section owns its own value + onChange — the phase is pure presentation. Set clearable: true to let users toggle the current selection back to null by re-tapping it (useful for optional sections).
  5. Per-option accent. Each option may supply an accent colour (brand red, brand blue, …). The selected state mixes that colour into the tile background and ring at a fixed 12% / 100% alpha against the surface, so dark and light themes both render correctly.
  6. Stagger. Header, sections, options, and CTA all fade and lift in on the same STAGGER rhythm. Reduced-motion users skip the lift; the entrance collapses to instant.
  7. Hit area. Every option tile / row and the advance button enforce a 44 × 44 px minimum hit area, regardless of the visible label length.

Props

PropTypeDefaultDescription
eyebrowReactNodeSmall accent line rendered above the title.
titleReactNoderequiredMain heading for the phase.
subtitleReactNodeSupporting paragraph beneath the title.
sectionsReadonlyArray<PersonalizePhaseSection>requiredOrdered picker sections.
advanceLabelReactNode"Continue"Label for the advance button.
onAdvance() => voidFires when the user taps the advance button.
advanceDisabledbooleanfalseDisables the advance button.
advanceHintReactNodeOptional helper rendered above the advance button.
transitionTransitionSPRINGS.smoothOverride entrance transition. Reduced-motion snaps regardless.
classNamestringMerged onto the root via cn().

PersonalizePhaseSection

FieldTypeDefaultDescription
idstringrequiredStable identifier — also keys the section in your state.
titleReactNoderequiredSection heading.
subtitleReactNodeMuted subtitle rendered under the heading.
optionsReadonlyArray<PersonalizePhaseOption>requiredOptions the user picks from.
valuestring | nullrequiredCurrently selected option id, or null.
onChange(next, option) => voidrequiredFires on selection. next is null when a clearable selection is re-tapped.
layout"grid" | "list""grid"Grid of icon tiles vs vertical row list.
clearablebooleanfalseAllow re-tapping the selected option to clear it.
gridCols2 | 3 | 43Column count for the grid layout.

PersonalizePhaseOption

FieldTypeDescription
idstringStable identifier — also the value emitted via onChange.
labelReactNodeVisible label.
iconReactNodeOptional icon rendered above (grid) or beside (list) the label.
descriptionReactNodeOptional supporting line (list layout).
metaReactNodeOptional muted micro-line under the label.
recommendedbooleanRender a "Recommended" pill next to the label (list layout).
accentstringPer-option accent colour. Falls back to --cb-accent.
ariaLabelstringOverride the accessible name when the visible label is too terse.

Accessibility

  • The sections grid is wrapped in role="group" and labelled by the title id, so assistive tech navigates the pickers as a single personalisation unit.
  • Every section exposes its own labelled region (aria-labelledby -> the section title id) so each picker is independently navigable.
  • Option tiles + rows use aria-pressed to expose selection state and a data-state="on"|"off" attribute as a styling hook.
  • Per-option ariaLabel overrides the visible label when an icon-only or numeric label needs a spelt-out screen-reader name.
  • Every option and the advance button enforce a 44 × 44 px minimum hit area to meet WCAG 2.5.8.
  • The advance button surfaces aria-disabled whenever advanceDisabled is true, so assistive tech surfaces the unmet precondition.
  • Selection is never colour-only: selected tiles add a 1.5 px inset ring at the accent colour on top of the colour-mixed background.
  • Motion respects prefers-reduced-motion: reduce — header, section, option, and CTA entrance lifts collapse to instant opacity-only fades, and the advance tap-scale is suppressed.

Credits

  • Extracted from: AlgoFlashcards (src/platform/ui/onboarding/PersonalizePhase.tsx). The source bound the phase to a hard-coded Company enum, a Timeline array, project-specific Button / Badge / CompanyIcon primitives, a track-colour brand lookup, and the SPRING.smooth + ELEMENT_ENTER.md / PHASE_TRANSITION shorthands from the project's motion module. The library extract collapses the two bespoke pickers into a single generic sections API (each section is either an icon grid or a row list), surfaces per-option accent so callers paint their own brand colours, lifts every selection into the caller's state via value + onChange, routes the accent through the --cb-* token ramp, and standardises the motion to SPRINGS.smooth + STAGGER so it plays well with the rest of the onboarding suite.