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.jsonUsage
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
- Header band. An optional accent-coloured
eyebrow, a balancedtitle, and a pretty-wrappedsubtitle. Centres the lesson before the pickers appear. - Sections grid. One column under
lg, two columns fromlgupwards when more than one section is supplied. Each section renders in its own card with a heading + optional subtitle + the picker body. - Layouts. A
"grid"section produces an icon-tile grid keyed offgridCols(2/3/4). A"list"section produces a vertical stack of row buttons — better for plans, tiers, or anything with a long secondary description. - Selection semantics. Each section owns its own
value+onChange— the phase is pure presentation. Setclearable: trueto let users toggle the current selection back tonullby re-tapping it (useful for optional sections). - Per-option accent. Each option may supply an
accentcolour (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. - Stagger. Header, sections, options, and CTA all fade and lift in on the same
STAGGERrhythm. Reduced-motion users skip the lift; the entrance collapses to instant. - Hit area. Every option tile / row and the advance button enforce a
44 × 44 pxminimum hit area, regardless of the visible label length.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
eyebrow | ReactNode | — | Small accent line rendered above the title. |
title | ReactNode | required | Main heading for the phase. |
subtitle | ReactNode | — | Supporting paragraph beneath the title. |
sections | ReadonlyArray<PersonalizePhaseSection> | required | Ordered picker sections. |
advanceLabel | ReactNode | "Continue" | Label for the advance button. |
onAdvance | () => void | — | Fires when the user taps the advance button. |
advanceDisabled | boolean | false | Disables the advance button. |
advanceHint | ReactNode | — | Optional helper rendered above the advance button. |
transition | Transition | SPRINGS.smooth | Override entrance transition. Reduced-motion snaps regardless. |
className | string | — | Merged onto the root via cn(). |
PersonalizePhaseSection
| Field | Type | Default | Description |
|---|---|---|---|
id | string | required | Stable identifier — also keys the section in your state. |
title | ReactNode | required | Section heading. |
subtitle | ReactNode | — | Muted subtitle rendered under the heading. |
options | ReadonlyArray<PersonalizePhaseOption> | required | Options the user picks from. |
value | string | null | required | Currently selected option id, or null. |
onChange | (next, option) => void | required | Fires on selection. next is null when a clearable selection is re-tapped. |
layout | "grid" | "list" | "grid" | Grid of icon tiles vs vertical row list. |
clearable | boolean | false | Allow re-tapping the selected option to clear it. |
gridCols | 2 | 3 | 4 | 3 | Column count for the grid layout. |
PersonalizePhaseOption
| Field | Type | Description |
|---|---|---|
id | string | Stable identifier — also the value emitted via onChange. |
label | ReactNode | Visible label. |
icon | ReactNode | Optional icon rendered above (grid) or beside (list) the label. |
description | ReactNode | Optional supporting line (list layout). |
meta | ReactNode | Optional muted micro-line under the label. |
recommended | boolean | Render a "Recommended" pill next to the label (list layout). |
accent | string | Per-option accent colour. Falls back to --cb-accent. |
ariaLabel | string | Override 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-pressedto expose selection state and adata-state="on"|"off"attribute as a styling hook. - Per-option
ariaLabeloverrides 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 pxminimum hit area to meet WCAG 2.5.8. - The advance button surfaces
aria-disabledwheneveradvanceDisabledis true, so assistive tech surfaces the unmet precondition. - Selection is never colour-only: selected tiles add a
1.5 pxinset 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-codedCompanyenum, aTimelinearray, project-specificButton/Badge/CompanyIconprimitives, a track-colour brand lookup, and theSPRING.smooth+ELEMENT_ENTER.md/PHASE_TRANSITIONshorthands 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-optionaccentso callers paint their own brand colours, lifts every selection into the caller's state viavalue+onChange, routes the accent through the--cb-*token ramp, and standardises the motion toSPRINGS.smooth+STAGGERso it plays well with the rest of the onboarding suite.