{
  "name": "onboarding-form-default",
  "type": "registry:block",
  "tier": "free",
  "dependencies": [
    "motion",
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "https://www.craftui.space/r/craftui-base.json",
    "radio-group",
    "select",
    "checkbox",
    "input",
    "textarea",
    "button"
  ],
  "cssVars": {
    "light": {
      "onb-surface": "var(--muted, oklch(0.97 0 0))",
      "onb-card": "var(--background, oklch(1 0 0))",
      "onb-rail": "oklch(0.925 0.004 70)",
      "onb-fg": "var(--foreground, oklch(0.145 0 0))",
      "onb-fg-muted": "oklch(0.45 0 0)",
      "onb-border": "oklch(0.8 0 0)",
      "onb-accent": "oklch(0.145 0 0)",
      "onb-accent-fg": "oklch(1 0 0)",
      "onb-selected": "oklch(0.88 0 0)",
      "onb-card-edge": "oklch(0 0 0 / 6%)",
      "onb-danger": "oklch(0.48 0.19 27.3)"
    },
    "dark": {
      "onb-surface": "var(--background, oklch(0.145 0 0))",
      "onb-card": "oklch(0.205 0 0)",
      "onb-rail": "oklch(0.262 0.003 70)",
      "onb-fg": "var(--foreground, oklch(0.985 0 0))",
      "onb-fg-muted": "oklch(0.85 0 0)",
      "onb-border": "oklch(0.6 0 0)",
      "onb-accent": "oklch(0.985 0 0)",
      "onb-accent-fg": "oklch(0.205 0 0)",
      "onb-selected": "oklch(0.47 0 0)",
      "onb-card-edge": "oklch(1 0 0 / 10%)",
      "onb-danger": "oklch(0.8 0.1 27.3)"
    }
  },
  "files": [
    {
      "path": "components/craftui/blocks/onboarding-form/default.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"motion/react\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  AlertCircleIcon,\n  ArrowLeft01Icon,\n  ArrowRight01Icon,\n  Tick02Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\nimport {\n  Select,\n  SelectContent,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport { Input } from \"@/components/ui/input\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { Button } from \"@/components/ui/button\";\n\n// ── Types ─────────────────────────────────────────────────────────────────────\n\ntype StepType = \"radio\" | \"select\" | \"multi\" | \"text\" | \"textarea\";\n\ninterface Step {\n  label: string;\n  question: string;\n  /** Defaults to \"radio\", so a steps array written before this existed still works. */\n  type?: StepType;\n  /** Required by radio, select and multi; ignored by text and textarea. */\n  options?: string[];\n  placeholder?: string;\n  /** Lets the user move on without answering, and shows a Skip button. */\n  optional?: boolean;\n}\n\n/** \"multi\" answers are string[]; every other type answers with a string. */\ntype Answer = string | string[];\n\ninterface OnboardingFormProps {\n  steps?: Step[];\n  onComplete?: (answers: Record<number, Answer>) => void;\n}\n\n// ── Constants ─────────────────────────────────────────────────────────────────\n\n/* Each control is chosen by the shape of its question, not to tick off a type:\n * short exclusive lists are cards, a long list is a dropdown, genuinely\n * multiple answers are checkboxes, open-ended answers are text. All five field\n * types ship here so they can be swapped straight into your own questions. */\nconst DEFAULT_STEPS: Step[] = [\n  {\n    label: \"Your Role\",\n    question: \"What best describes your role?\",\n    type: \"radio\",\n    options: [\"Designer\", \"Developer\", \"Product Manager\", \"Other\"],\n  },\n  {\n    label: \"Team Size\",\n    question: \"How large is your team?\",\n    // Eight buckets: past four or five options, cards stop scanning well and a\n    // dropdown is the better control.\n    type: \"select\",\n    options: [\n      \"Just me\",\n      \"2–5 people\",\n      \"6–10 people\",\n      \"11–25 people\",\n      \"26–50 people\",\n      \"51–100 people\",\n      \"101–250 people\",\n      \"250+ people\",\n    ],\n    placeholder: \"Choose a team size\",\n  },\n  {\n    label: \"Primary Goal\",\n    question: \"What do you want out of this tool?\",\n    type: \"multi\",\n    options: [\n      \"Ship faster\",\n      \"Better collaboration\",\n      \"Track progress\",\n      \"Reduce busywork\",\n    ],\n  },\n  {\n    label: \"Current Tool\",\n    question: \"Which tool are you moving from?\",\n    type: \"text\",\n    placeholder: \"e.g. Notion, Jira, or nothing yet\",\n  },\n  {\n    label: \"Discovery\",\n    question: \"How did you hear about us?\",\n    type: \"textarea\",\n    placeholder: \"Share as much or as little as you like\",\n    optional: true,\n  },\n];\n\nconst ROW_HEIGHT = 64;\n/** Completed rows compress to this, so the remaining path visibly shortens. */\nconst ROW_HEIGHT_DONE = 44;\nconst ACTIVE_ROW = 2;\nconst DOT_TOP = ACTIVE_ROW * ROW_HEIGHT;\nconst DOT_OFFSET = 20;\n/** Rows the ticker window shows at once. Seven, not five: the two leading rows\n *  are empty padding, so a five-row window showed only three steps on step one\n *  and left the rail looking truncated with ~200px dead below it. Seven fits the\n *  active step plus every remaining one at the start. The cost is more slack\n *  below the active row on the last step, once completed rows have compressed\n *  and scrolled up — a worse final step in exchange for a better first one. */\nconst VISIBLE_ROWS = 7;\n\n/** One duration for everything a step change moves — rail scroll, row\n *  compression, progress bar and question panel. They fire on the same click\n *  and sit side by side, so finishing at different times read as lag. It was\n *  also inverted before: the rail travelled one row in 500ms while the panel\n *  covered ~370px in 380ms, so the shortest move took the longest. 300ms is the\n *  ceiling for UI motion, and this fires five times in about a minute. */\nconst STEP_DUR = 0.3;\n/** Exits run shorter than entrances — leaving should clear out faster. */\nconst EXIT_RATIO = 0.8;\n\ntype Bezier = readonly [number, number, number, number];\n/** The ticker scrolls and its labels shift: elements already on screen moving,\n *  which is the ease-in-out case. */\nconst EASING_MOVE: Bezier = [0.645, 0.045, 0.355, 1];\n/** The question panel enters and leaves the viewport, which is ease-out. */\nconst EASING_ENTER: Bezier = [0.165, 0.84, 0.44, 1];\n\nconst ERROR_BY_TYPE: Record<StepType, string> = {\n  radio: \"Select an option to continue.\",\n  select: \"Choose an option to continue.\",\n  multi: \"Select at least one option to continue.\",\n  text: \"Enter an answer to continue.\",\n  textarea: \"Enter an answer to continue.\",\n};\n\n/** First focusable control of any field type, for focusing the failed step. */\nconst FIELD_FOCUS_SELECTOR =\n  \"[role=radio],[data-slot=select-trigger],[data-slot=checkbox],[data-slot=input],[data-slot=textarea]\";\n\n/** Exit runs at 80% of the entrance: leaving should get out of the way faster\n *  than arriving. Per-variant transitions are the only way to split the two,\n *  since one `transition` prop covers both directions. */\nconst makeSlideVariants = (enter: object, exit: object) => ({\n  initial: (dir: number) => ({ x: `${80 * dir}%`, opacity: 0 }),\n  active: { x: \"0%\", opacity: 1, transition: enter },\n  exit: (dir: number) => ({ x: `${-80 * dir}%`, opacity: 0, transition: exit }),\n});\n\n/* Type scale — four sizes only: 24 / 18 / 14 / 12.\n * 24  text-2xl  the question, and the completion heading\n * 18  text-lg   the active rail label\n * 14  text-sm   options, field values, completion body\n * 12  text-xs   step counter, rail eyebrow, numerals, hint, error\n * The question outranks the rail on purpose: the rail is aria-hidden decoration\n * and was previously the largest type on screen. */\n\n/** One focus treatment for every control in the block.\n *\n *  The five primitives disagree out of the box — Input ships ring-1 plus\n *  ring-offset-1, Textarea ring-1 with no offset, SelectTrigger ring-[3px],\n *  RadioGroupItem and Checkbox ring-3 — so tabbing through changed the\n *  indicator's shape, width and offset on almost every stop. The rings are\n *  suppressed and replaced by a single outline. No colour is set, so it renders\n *  currentColor and adapts per surface instead of hardcoding a brand value.\n *  Change it here and every control follows. */\nconst FOCUS_CLASS =\n  \"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-1 focus-visible:outline-offset-0\";\n\n/** Shared token styling for the boxed controls (select trigger, input, textarea).\n *  The aria-invalid overrides replace shadcn's --destructive, which measures\n *  Lc 70.1 light / -46.5 dark against this card and fails both.\n *  shadow-none drops the shadcn shadow-xs: the option cards carry no shadow, and\n *  the two sat side by side at the same width, radius and padding with different\n *  depth. This block's language is flat, so the fields lose the shadow rather\n *  than the cards gaining one. */\nconst FIELD_CLASS =\n  `w-full rounded-xl border-[var(--onb-border)] bg-transparent text-sm text-[var(--onb-fg)] shadow-none placeholder:text-[var(--onb-fg-muted)] aria-invalid:border-[var(--onb-danger)] aria-invalid:ring-0 dark:bg-transparent dark:aria-invalid:border-[var(--onb-danger)] ${FOCUS_CLASS}`;\n\n/** Shared card styling for the radio and multi options, so both read the same.\n *  Selected is a tint plus an accent border, not a solid accent fill: a solid\n *  fill matched the Next button's weight, so the answer and the action read as\n *  equally important. Fill weight now says \"chosen\"; only the button says \"act\". */\nconst optionCardClass = (isSelected: boolean, invalid: boolean) => {\n  // The card, not the 16px indicator, is what shows focus — hence the has-[]\n  // form of FOCUS_CLASS's outline.\n  const base =\n    \"flex min-h-11 cursor-pointer items-center gap-3 rounded-xl border-1 px-4 py-3 text-sm font-medium transition-colors duration-150 ease-[ease] has-[:focus-visible]:outline-1 has-[:focus-visible]:outline-offset-0\";\n  if (isSelected)\n    return `${base} border-[var(--onb-accent)] bg-[var(--onb-selected)] text-[var(--onb-fg)]`;\n  // Nothing is selected when the group is invalid, so the danger border is the\n  // only way the cards themselves show the failure.\n  if (invalid)\n    return `${base} border-[var(--onb-danger)] bg-[var(--onb-card)] text-[var(--onb-fg)]`;\n  return `${base} border-[var(--onb-border)] bg-[var(--onb-card)] text-[var(--onb-fg)] hover:border-[var(--onb-fg-muted)]`;\n};\n\n/** Both indicators inherit currentColor, which the card already sets per state,\n *  instead of shadcn's data-checked:bg-primary / text-primary-foreground.\n *\n *  The 150ms pairs the indicator with its card, which already transitions at\n *  150ms — they read as one element, so they should resolve together. Radix\n *  mounts the dot and the tick only once checked, so their arrival needs a\n *  mount animation rather than a transition; `[&>span]` is the indicator\n *  wrapper in both components. globals.css collapses `animate-in` under\n *  prefers-reduced-motion. */\nconst INDICATOR_CLASS =\n  \"shrink-0 border-current bg-transparent text-current transition-colors duration-150 ease-[ease] focus-visible:ring-0 data-checked:border-current data-checked:bg-transparent data-checked:text-current dark:bg-transparent dark:data-checked:bg-transparent [&>span]:animate-in [&>span]:fade-in-0 [&>span]:zoom-in-75 [&>span]:duration-150\";\n\nconst isAnswered = (answer: Answer | undefined) =>\n  Array.isArray(answer) ? answer.length > 0 : Boolean(answer?.trim());\n\n/** Row heights drive both the rail's layout and its scroll offset, so they have\n *  to be derived from one function — see yOffsetFor. */\nconst rowHeight = (stepIndex: number, activeStep: number) =>\n  stepIndex < activeStep ? ROW_HEIGHT_DONE : ROW_HEIGHT;\n\n/** The active row must sit under the fixed dot at every resting state. Summing\n *  the real heights above it is what keeps that true once completed rows\n *  compress — and because this offset and the heights animate under one shared\n *  transition, the active row stays pinned to the dot mid-flight too: both\n *  sides are lerps over the same easing, so their sum is constant. */\nconst yOffsetFor = (steps: Step[], activeStep: number) => {\n  let above = 0;\n  for (let i = 0; i < activeStep + ACTIVE_ROW; i++) {\n    const stepIndex = i - ACTIVE_ROW;\n    const isPadding = stepIndex < 0 || stepIndex >= steps.length;\n    above += isPadding ? ROW_HEIGHT : rowHeight(stepIndex, activeStep);\n  }\n  return DOT_TOP - above;\n};\n\n// ── StepLabel ─────────────────────────────────────────────────────────────────\n// Colour and weight are class-driven rather than Motion-animated: Motion cannot\n// interpolate `var(--token)`, and animating literal hex values is what stopped\n// this rail from theming at all. Motion keeps the x-offset and the height; CSS\n// transitions the colour, and globals.css collapses those under reduced motion.\n\nfunction StepLabel({\n  step,\n  stepIndex,\n  isActive,\n  isDone,\n  height,\n  heightTransition,\n  labelTransition,\n}: {\n  step: Step;\n  stepIndex: number;\n  isActive: boolean;\n  isDone: boolean;\n  height: number;\n  heightTransition: object;\n  labelTransition: object;\n}) {\n  return (\n    <motion.div\n      className=\"flex items-center overflow-hidden\"\n      initial={false}\n      animate={{ height }}\n      transition={heightTransition}\n    >\n      <motion.div\n        animate={{ x: isActive ? DOT_OFFSET : 0 }}\n        transition={labelTransition}\n        className=\"flex flex-col gap-0.5\"\n      >\n        <span\n          className={`flex h-4 items-center font-mono text-xs tracking-widest transition-colors duration-200 ${\n            isActive ? \"text-[var(--onb-fg)]\" : \"text-[var(--onb-fg-muted)]\"\n          }`}\n        >\n          {isDone ? (\n            <HugeiconsIcon\n              icon={Tick02Icon}\n              size={13}\n              strokeWidth={2}\n              color=\"currentColor\"\n              aria-hidden=\"true\"\n            />\n          ) : (\n            String(stepIndex + 1).padStart(2, \"0\")\n          )}\n        </span>\n        <span\n          className={`text-lg leading-tight whitespace-nowrap transition-colors duration-200 ${\n            isActive\n              ? \"font-normal text-[var(--onb-fg)]\"\n              : \"font-extralight text-[var(--onb-fg-muted)]\"\n          }`}\n        >\n          {step.label}\n        </span>\n      </motion.div>\n    </motion.div>\n  );\n}\n\n// ── StepTicker ────────────────────────────────────────────────────────────────\n// Decorative: it duplicates progress that the live region already announces, so\n// it is hidden from assistive tech rather than read out as a run of loose text.\n\nfunction StepTicker({\n  steps,\n  activeStep,\n  scrollTransition,\n  labelTransition,\n}: {\n  steps: Step[];\n  activeStep: number;\n  /** Drives the scroll *and* the row heights — they must share one transition\n   *  or the active row drifts off the dot mid-animation. */\n  scrollTransition: object;\n  labelTransition: object;\n}) {\n  const padded = [\n    ...Array(ACTIVE_ROW).fill(null),\n    ...steps,\n    ...Array(ACTIVE_ROW).fill(null),\n  ];\n\n  return (\n    <div\n      aria-hidden=\"true\"\n      className=\"flex w-56 shrink-0 flex-col rounded-lg bg-[var(--onb-rail)] py-8\"\n    >\n      <div className=\"mb-6 px-6\">\n        <p className=\"text-xs font-semibold tracking-tight text-[var(--onb-fg-muted)] uppercase\">\n          Getting started\n        </p>\n      </div>\n\n      <div\n        className=\"relative overflow-hidden\"\n        style={{ height: ROW_HEIGHT * VISIBLE_ROWS }}\n      >\n        {/* Fixed dot — never moves */}\n        <div\n          className=\"pointer-events-none absolute left-6 z-10 flex items-center\"\n          style={{ top: DOT_TOP, height: ROW_HEIGHT }}\n        >\n          <div className=\"h-2.5 w-2.5 rounded-full bg-[var(--onb-fg)]\" />\n        </div>\n\n        {/* Scrolling step labels */}\n        <motion.div\n          initial={false}\n          animate={{ y: yOffsetFor(steps, activeStep) }}\n          transition={scrollTransition}\n          className=\"absolute top-0 left-6 w-full\"\n        >\n          {padded.map((step, i) => {\n            if (!step)\n              return <div key={`pad-${i}`} style={{ height: ROW_HEIGHT }} />;\n            const stepIndex = i - ACTIVE_ROW;\n            return (\n              <StepLabel\n                key={`step-${stepIndex}`}\n                step={step}\n                stepIndex={stepIndex}\n                isActive={stepIndex === activeStep}\n                isDone={stepIndex < activeStep}\n                height={rowHeight(stepIndex, activeStep)}\n                heightTransition={scrollTransition}\n                labelTransition={labelTransition}\n              />\n            );\n          })}\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\n// ── StepMeter ─────────────────────────────────────────────────────────────────\n// The single owner of \"how far along am I\". Absolutely positioned rather than in\n// the content flow for two reasons: it must not shift when a taller question or\n// a textarea changes the centred stack's height, and it belongs next to the nav\n// buttons — that is where the eye is at the moment the user decides to move on,\n// so it can say how far is left while they are deciding.\n//\n// The track matters as much as the fill: without it the bar is a stray rule with\n// nothing to show what fraction it represents.\n\nfunction StepMeter({\n  activeStep,\n  totalSteps,\n  optional,\n  transition,\n}: {\n  activeStep: number;\n  totalSteps: number;\n  optional: boolean;\n  transition: object;\n}) {\n  const current = activeStep + 1;\n  return (\n    // h-11 matches the nav buttons' height so the two sit on one optical line.\n    <div className=\"absolute bottom-10 left-10 flex h-11 w-56 flex-col justify-center gap-2\">\n      <p className=\"text-xs font-semibold tracking-widest whitespace-nowrap text-[var(--onb-fg-muted)] uppercase\">\n        Step {current} of {totalSteps}\n        {optional && (\n          <span className=\"ml-2 normal-case opacity-70\">· optional</span>\n        )}\n      </p>\n      <div\n        role=\"progressbar\"\n        aria-valuemin={1}\n        aria-valuemax={totalSteps}\n        aria-valuenow={current}\n        aria-valuetext={`Step ${current} of ${totalSteps}`}\n        aria-label=\"Onboarding progress\"\n        className=\"h-0.5 w-full overflow-hidden rounded-full bg-[var(--onb-border)]\"\n      >\n        {/* scaleX rather than width, so this stays composite-only. */}\n        <motion.div\n          initial={false}\n          animate={{ scaleX: current / totalSteps }}\n          transition={transition}\n          className=\"h-full w-full origin-left rounded-full bg-[var(--onb-accent)]\"\n        />\n      </div>\n    </div>\n  );\n}\n\n// ── QuestionField ─────────────────────────────────────────────────────────────\n// One field per step type. Every branch takes its accessible name from the\n// question heading, so the visible question is the control's programmatic label\n// and no placeholder is ever doing that job.\n\nfunction QuestionField({\n  step,\n  stepIndex,\n  answer,\n  headingId,\n  describedBy,\n  invalid,\n  onChange,\n}: {\n  step: Step;\n  stepIndex: number;\n  answer: Answer | undefined;\n  headingId: string;\n  describedBy: string | undefined;\n  invalid: boolean;\n  onChange: (value: Answer) => void;\n}) {\n  const type = step.type ?? \"radio\";\n  const options = step.options ?? [];\n  const fieldId = `onb-field-${stepIndex}`;\n  const value = typeof answer === \"string\" ? answer : \"\";\n\n  if (type === \"select\") {\n    const valueId = `${fieldId}-value`;\n    return (\n      <Select value={value} onValueChange={onChange}>\n        {/* Named by the question *and* the current value, so the answer is\n            still announced; aria-labelledby alone would suppress it. */}\n        <SelectTrigger\n          id={fieldId}\n          aria-labelledby={`${headingId} ${valueId}`}\n          aria-invalid={invalid || undefined}\n          aria-describedby={describedBy}\n          className={`${FIELD_CLASS} h-11 data-[placeholder]:text-[var(--onb-fg-muted)]`}\n        >\n          <SelectValue\n            id={valueId}\n            placeholder={step.placeholder ?? \"Choose an option\"}\n          />\n        </SelectTrigger>\n        {/* ease-out sets --tw-ease, which tw-animate-css's animate-in/out read\n            in place of their `ease` default: the panel enters and exits the\n            viewport. Set here rather than in ui/select.tsx so consumers keep\n            the pristine shadcn component. */}\n        <SelectContent className=\"rounded-xl ease-out data-[state=closed]:duration-[120ms]\">\n          {options.map((option) => (\n            <SelectItem key={option} value={option} className=\"text-sm\">\n              {option}\n            </SelectItem>\n          ))}\n        </SelectContent>\n      </Select>\n    );\n  }\n\n  if (type === \"multi\") {\n    const list = Array.isArray(answer) ? answer : [];\n    return (\n      <div\n        role=\"group\"\n        aria-labelledby={headingId}\n        aria-describedby={describedBy}\n        className=\"flex flex-col gap-2\"\n      >\n        {options.map((option, i) => {\n          const id = `onb-${stepIndex}-${i}`;\n          const checked = list.includes(option);\n          return (\n            <label\n              key={option}\n              htmlFor={id}\n              className={optionCardClass(checked, invalid)}\n            >\n              <Checkbox\n                id={id}\n                checked={checked}\n                aria-invalid={invalid || undefined}\n                onCheckedChange={() =>\n                  onChange(\n                    checked\n                      ? list.filter((v) => v !== option)\n                      : [...list, option],\n                  )\n                }\n                className={INDICATOR_CLASS}\n              />\n              {option}\n            </label>\n          );\n        })}\n      </div>\n    );\n  }\n\n  if (type === \"text\") {\n    return (\n      <Input\n        id={fieldId}\n        type=\"text\"\n        aria-labelledby={headingId}\n        aria-invalid={invalid || undefined}\n        aria-describedby={describedBy}\n        placeholder={step.placeholder}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        className={`${FIELD_CLASS} h-11 px-4`}\n      />\n    );\n  }\n\n  if (type === \"textarea\") {\n    return (\n      <Textarea\n        id={fieldId}\n        rows={4}\n        aria-labelledby={headingId}\n        aria-invalid={invalid || undefined}\n        aria-describedby={describedBy}\n        placeholder={step.placeholder}\n        value={value}\n        onChange={(e) => onChange(e.target.value)}\n        // Enter inserts a newline in a textarea, so submitting needs the\n        // platform's ⌘/Ctrl+Enter instead.\n        onKeyDown={(e) => {\n          if ((e.metaKey || e.ctrlKey) && e.key === \"Enter\") {\n            e.preventDefault();\n            e.currentTarget.form?.requestSubmit();\n          }\n        }}\n        className={`${FIELD_CLASS} min-h-24 px-4 py-3`}\n      />\n    );\n  }\n\n  return (\n    <RadioGroup\n      aria-labelledby={headingId}\n      aria-invalid={invalid || undefined}\n      aria-describedby={describedBy}\n      value={value}\n      onValueChange={onChange}\n      className=\"flex flex-col gap-2\"\n    >\n      {options.map((option, i) => {\n        const id = `onb-${stepIndex}-${i}`;\n        return (\n          <label\n            key={option}\n            htmlFor={id}\n            className={optionCardClass(value === option, invalid)}\n          >\n            {/* The indicator is scoped to the dot inside it: Radix renders the\n                indicator itself as a <span> too, so a bare [&_span] would paint\n                the whole 16px wrapper instead of the dot. */}\n            <RadioGroupItem\n              id={id}\n              value={option}\n              className={`${INDICATOR_CLASS} [&_[data-slot=radio-group-indicator]>span]:bg-current`}\n            />\n            {option}\n          </label>\n        );\n      })}\n    </RadioGroup>\n  );\n}\n\n// ── QuestionSlide ─────────────────────────────────────────────────────────────\n\nfunction QuestionSlide({\n  step,\n  stepIndex,\n  answer,\n  direction,\n  transition,\n  exitTransition,\n  error,\n  errorId,\n  fieldRef,\n  onChange,\n}: {\n  step: Step;\n  stepIndex: number;\n  answer: Answer | undefined;\n  direction: number;\n  transition: object;\n  exitTransition: object;\n  error: string;\n  errorId: string;\n  fieldRef: React.RefObject<HTMLDivElement | null>;\n  onChange: (value: Answer) => void;\n}) {\n  const headingId = `onb-q-${stepIndex}`;\n  const hintId = `onb-hint-${stepIndex}`;\n  const isTextarea = step.type === \"textarea\";\n  const slideVariants = makeSlideVariants(transition, exitTransition);\n\n  const describedBy =\n    [error ? errorId : null, isTextarea ? hintId : null]\n      .filter(Boolean)\n      .join(\" \") || undefined;\n\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n      <motion.div\n        key={stepIndex}\n        variants={slideVariants}\n        initial=\"initial\"\n        animate=\"active\"\n        exit=\"exit\"\n        custom={direction}\n        className=\"w-full\"\n      >\n        <h2\n          id={headingId}\n          className=\"mb-5 text-2xl leading-snug font-bold text-[var(--onb-fg)]\"\n        >\n          {step.question}\n        </h2>\n\n        <div ref={fieldRef}>\n          <QuestionField\n            step={step}\n            stepIndex={stepIndex}\n            answer={answer}\n            headingId={headingId}\n            describedBy={describedBy}\n            invalid={Boolean(error)}\n            onChange={onChange}\n          />\n        </div>\n\n        {isTextarea && (\n          <p id={hintId} className=\"mt-2 text-xs text-[var(--onb-fg-muted)]\">\n            Press ⌘ + Enter to finish.\n          </p>\n        )}\n\n        {/* Reserved so the message never shifts the field on appearing. The\n            icon is what separates this from the hint above, which otherwise\n            shares its size, colour and position. */}\n        <p\n          id={errorId}\n          className=\"mt-2 flex min-h-5 items-center gap-1.5 text-xs font-medium text-[var(--onb-danger)]\"\n        >\n          {error && (\n            <HugeiconsIcon\n              icon={AlertCircleIcon}\n              size={14}\n              strokeWidth={2}\n              color=\"currentColor\"\n              aria-hidden=\"true\"\n              className=\"shrink-0\"\n            />\n          )}\n          {error}\n        </p>\n      </motion.div>\n    </AnimatePresence>\n  );\n}\n\n// ── NavButtons ────────────────────────────────────────────────────────────────\n// Next stays enabled even without an answer: pressing it surfaces the error and\n// moves focus to the field, which is what tells the user what to fix. Back is\n// natively disabled on step one, where it is genuinely unavailable.\n\nfunction NavButtons({\n  isFirst,\n  isLast,\n  isOptional,\n  onBack,\n  onSkip,\n}: {\n  isFirst: boolean;\n  isLast: boolean;\n  isOptional: boolean;\n  onBack: () => void;\n  onSkip: () => void;\n}) {\n  // Equal 40px inset on both axes, and right-10 keeps the cluster on the same\n  // vertical edge as the question and options above it.\n  return (\n    <div className=\"absolute right-10 bottom-10 flex items-center gap-2\">\n      {isOptional && (\n        <Button\n          type=\"button\"\n          onClick={onSkip}\n          className={`mr-1 h-11 rounded-full bg-transparent px-4 text-sm font-medium text-[var(--onb-fg-muted)] hover:bg-[var(--onb-surface)] hover:text-[var(--onb-fg)] ${FOCUS_CLASS}`}\n        >\n          Skip\n        </Button>\n      )}\n\n      <Button\n        type=\"button\"\n        onClick={onBack}\n        disabled={isFirst}\n        aria-label=\"Previous step\"\n        className={`size-11 rounded-full bg-[var(--onb-surface)] text-[var(--onb-fg)] hover:bg-[var(--onb-border)] ${FOCUS_CLASS}`}\n      >\n        <HugeiconsIcon\n          icon={ArrowLeft01Icon}\n          size={16}\n          strokeWidth={2}\n          color=\"currentColor\"\n          aria-hidden=\"true\"\n        />\n      </Button>\n\n      <Button\n        type=\"submit\"\n        aria-label={isLast ? \"Finish onboarding\" : \"Next step\"}\n        className={`size-11 rounded-full bg-[var(--onb-accent)] text-[var(--onb-accent-fg)] hover:bg-[var(--onb-accent)]/90 ${FOCUS_CLASS}`}\n      >\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {isLast ? (\n            <motion.span\n              key=\"tick\"\n              initial={{ opacity: 0, scale: 0.5 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.5 }}\n              transition={{ type: \"spring\", duration: 0.25, bounce: 0 }}\n              className=\"flex items-center\"\n            >\n              <HugeiconsIcon\n                icon={Tick02Icon}\n                size={16}\n                strokeWidth={2}\n                color=\"currentColor\"\n                aria-hidden=\"true\"\n              />\n            </motion.span>\n          ) : (\n            <motion.span\n              key=\"arrow\"\n              initial={{ opacity: 0, scale: 0.5 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0, scale: 0.5 }}\n              transition={{ type: \"spring\", duration: 0.25, bounce: 0 }}\n              className=\"flex items-center\"\n            >\n              <HugeiconsIcon\n                icon={ArrowRight01Icon}\n                size={16}\n                strokeWidth={2}\n                color=\"currentColor\"\n                aria-hidden=\"true\"\n              />\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </Button>\n    </div>\n  );\n}\n\n// ── CompletionScreen ──────────────────────────────────────────────────────────\n// Reflects the answers back rather than ending on a bare tick: five steps of\n// effort should visibly buy something. Built from steps + answers so it holds\n// for any steps array a consumer passes.\n\nfunction formatAnswer(answer: Answer | undefined) {\n  if (Array.isArray(answer))\n    return answer.length ? answer.join(\", \") : \"Skipped\";\n  const trimmed = answer?.trim();\n  return trimmed ? trimmed : \"Skipped\";\n}\n\nfunction CompletionScreen({\n  steps,\n  answers,\n  headingRef,\n}: {\n  steps: Step[];\n  answers: Record<number, Answer>;\n  headingRef: React.RefObject<HTMLHeadingElement | null>;\n}) {\n  return (\n    <motion.div\n      key=\"done\"\n      initial={{ opacity: 0, scale: 0.96 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0 }}\n      transition={{\n        type: \"tween\",\n        ease: [0.165, 0.84, 0.44, 1],\n        duration: 0.38,\n      }}\n      className=\"flex w-full flex-col items-center justify-center gap-4 rounded-lg bg-[var(--onb-card)] px-10\"\n    >\n      <div className=\"flex h-14 w-14 items-center justify-center rounded-full bg-[var(--onb-accent)] text-[var(--onb-accent-fg)]\">\n        <HugeiconsIcon\n          icon={Tick02Icon}\n          size={22}\n          strokeWidth={2}\n          color=\"currentColor\"\n          aria-hidden=\"true\"\n        />\n      </div>\n      {/* tabIndex -1 so focus can land here when the form unmounts, instead of\n          falling back to <body> with nothing announced. */}\n      <h2\n        ref={headingRef}\n        tabIndex={-1}\n        className={`text-2xl font-bold text-[var(--onb-fg)] ${FOCUS_CLASS}`}\n      >\n        All set!\n      </h2>\n      <p className=\"text-sm text-[var(--onb-fg-muted)]\">\n        Here&rsquo;s what you told us.\n      </p>\n\n      {/* Capped and scrollable so a long textarea answer can't push the card\n          out of shape; overscroll-contain keeps that scroll local. */}\n      <dl className=\"mt-1 flex max-h-56 w-full max-w-sm flex-col overflow-y-auto [overscroll-behavior:contain]\">\n        {steps.map((step, i) => {\n          const value = formatAnswer(answers[i]);\n          const skipped = value === \"Skipped\";\n          return (\n            <div\n              key={step.label}\n              className=\"flex items-baseline justify-between gap-4 border-b border-[var(--onb-border)] py-2 last:border-0\"\n            >\n              <dt className=\"shrink-0 text-xs text-[var(--onb-fg-muted)]\">\n                {step.label}\n              </dt>\n              <dd\n                className={`line-clamp-2 text-right text-sm ${\n                  skipped\n                    ? \"text-[var(--onb-fg-muted)] italic\"\n                    : \"text-[var(--onb-fg)]\"\n                }`}\n              >\n                {value}\n              </dd>\n            </div>\n          );\n        })}\n      </dl>\n    </motion.div>\n  );\n}\n\n// ── Root ──────────────────────────────────────────────────────────────────────\n\nexport default function OnboardingForm({\n  steps = DEFAULT_STEPS,\n  onComplete,\n}: OnboardingFormProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const [activeStep, setActiveStep] = useState(0);\n  const [direction, setDirection] = useState(1);\n  const [answers, setAnswers] = useState<Record<number, Answer>>({});\n  const [done, setDone] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [status, setStatus] = useState(\"\");\n\n  const uid = useId();\n  const errorId = `onb-error-${uid}`;\n  const fieldRef = useRef<HTMLDivElement | null>(null);\n  const doneHeadingRef = useRef<HTMLHeadingElement | null>(null);\n\n  const isFirst = activeStep === 0;\n  const isLast = activeStep === steps.length - 1;\n  const isOptional = Boolean(steps[activeStep].optional);\n  const hasAnswer = isAnswered(answers[activeStep]);\n\n  const tween = (duration: number, ease = EASING_MOVE) =>\n    shouldReduceMotion\n      ? { duration: 0 }\n      : { type: \"tween\" as const, ease, duration };\n\n  // Focus the completion heading once it exists, so focus never falls back to\n  // <body> when the form unmounts.\n  useEffect(() => {\n    if (done) doneHeadingRef.current?.focus();\n  }, [done]);\n\n  const announceStep = (index: number) =>\n    setStatus(`Step ${index + 1} of ${steps.length}. ${steps[index].question}`);\n\n  /** Moves forward without validating — the shared tail of Next and Skip. */\n  const advance = () => {\n    setError(\"\");\n    if (isLast) {\n      onComplete?.(answers);\n      setStatus(\"Onboarding complete. Your preferences have been saved.\");\n      setDone(true);\n      return;\n    }\n    setDirection(1);\n    setActiveStep(activeStep + 1);\n    announceStep(activeStep + 1);\n  };\n\n  const goNext = () => {\n    if (!hasAnswer && !isOptional) {\n      setError(ERROR_BY_TYPE[steps[activeStep].type ?? \"radio\"]);\n      fieldRef.current\n        ?.querySelector<HTMLElement>(FIELD_FOCUS_SELECTOR)\n        ?.focus();\n      return;\n    }\n    advance();\n  };\n\n  const goBack = () => {\n    if (isFirst) return;\n    setError(\"\");\n    setDirection(-1);\n    setActiveStep(activeStep - 1);\n    announceStep(activeStep - 1);\n  };\n\n  const setAnswer = (value: Answer) => {\n    setError(\"\");\n    setAnswers((prev) => ({ ...prev, [activeStep]: value }));\n  };\n\n  return (\n    <div className=\"flex h-full w-full items-center justify-center bg-[var(--onb-surface)] font-sans\">\n      {/* Live region is present from first paint and empty, so the messages\n          injected into it later actually get announced. */}\n      <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status}\n      </div>\n\n      {/* The shadow defines the card's edge in light mode and disappears\n          entirely in dark (black shadow on a near-black page), where the card\n          then differed from the page by 0.06 lightness and stopped reading as a\n          surface. The hairline carries the edge there. */}\n      <div className=\"relative flex h-[600px] w-[800px] overflow-hidden rounded-2xl bg-[var(--onb-card)] px-2 py-2 shadow-2xl ring-1 ring-[var(--onb-card-edge)]\">\n        <AnimatePresence mode=\"wait\">\n          {done ? (\n            <CompletionScreen\n              key=\"done\"\n              steps={steps}\n              answers={answers}\n              headingRef={doneHeadingRef}\n            />\n          ) : (\n            <motion.form\n              key=\"form\"\n              initial={false}\n              noValidate\n              onSubmit={(e) => {\n                e.preventDefault();\n                goNext();\n              }}\n              className=\"flex h-full w-full gap-2\"\n            >\n              <StepTicker\n                steps={steps}\n                activeStep={activeStep}\n                scrollTransition={tween(STEP_DUR)}\n                labelTransition={tween(STEP_DUR)}\n              />\n\n              {/* pb clears the nav cluster: 40px inset + 44px button + 12px. */}\n              <div className=\"relative flex flex-1 flex-col justify-center overflow-hidden rounded-lg bg-[var(--onb-card)] px-10 pb-24\">\n                <StepMeter\n                  activeStep={activeStep}\n                  totalSteps={steps.length}\n                  optional={isOptional}\n                  transition={tween(STEP_DUR, EASING_ENTER)}\n                />\n\n                <QuestionSlide\n                  step={steps[activeStep]}\n                  stepIndex={activeStep}\n                  answer={answers[activeStep]}\n                  direction={direction}\n                  transition={tween(STEP_DUR, EASING_ENTER)}\n                  exitTransition={tween(STEP_DUR * EXIT_RATIO, EASING_ENTER)}\n                  error={error}\n                  errorId={errorId}\n                  fieldRef={fieldRef}\n                  onChange={setAnswer}\n                />\n\n                <NavButtons\n                  isFirst={isFirst}\n                  isLast={isLast}\n                  isOptional={isOptional}\n                  onBack={goBack}\n                  onSkip={advance}\n                />\n              </div>\n            </motion.form>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "~/components/craftui/blocks/onboarding-form/default.tsx"
    }
  ],
  "meta": {
    "displayName": "Onboarding Form - Onboarding Form",
    "description": "Multi-step onboarding form with a vertical ticker step indicator on the left and an animated question panel on the right, covering five field types: radio, dropdown, multi-select, short text and long text.",
    "category": "multi-step-form",
    "tags": [
      "form",
      "onboarding",
      "multi-step",
      "ticker",
      "animation",
      "click"
    ],
    "animationType": "click"
  }
}