{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"steps","type":"registry:component","title":"Steps","description":"Where you are in a flow: the line to the next step fills along its length, the finished number blurs into a drawn check, and phones get \"Step 2 of 4\".","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/ui-interaction-motion.json?as=dependency"],"files":[{"path":"registry/steps.tsx","type":"registry:component","content":"// Hairline UI · steps · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { NumberRoll } from \"./number-roll\";\nimport { TextMorph } from \"./text-morph\";\nimport { EASE_OUT, EASE_THROW, INTERACTION_FOCUS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * STEPS: where you are in a flow\n *\n *   advance   the line to the next step fills along its length,\n *             the finished step's number blurs into a check that\n *             draws itself, and the new current step's ring pops in\n *   back      the line drains back and the check gives way to the\n *             number again\n *   narrow    in a small container it becomes \"Step 2 of 4 ·\n *             Payment\" with a filling bar; the number rolls and the\n *             step's name morphs\n *\n * Finished steps can be links back (`onStepClick`).\n * ───────────────────────────────────────────────────────── */\n\nexport interface Step {\n  label: string;\n  description?: string;\n}\n\nexport interface StepsProps {\n  steps: Step[];\n  /** Index of the current step (0-based). steps.length means all done. */\n  current: number;\n  /** Let people go back to finished steps. */\n  onStepClick?: (index: number) => void;\n  orientation?: \"horizontal\" | \"vertical\";\n  className?: string;\n}\n\nfunction Mark({ index, state, reduced }: { index: number; state: \"done\" | \"current\" | \"upcoming\"; reduced: boolean }) {\n  const done = state === \"done\";\n  return (\n    <span\n      className={`relative flex size-7 shrink-0 items-center justify-center rounded-full text-[12px] font-medium tabular-nums transition-[background-color,color,box-shadow] duration-300 ${\n        done\n          ? \"bg-primary text-primary-foreground\"\n          : state === \"current\"\n            ? \"bg-background text-foreground shadow-[inset_0_0_0_1.5px_var(--primary)]\"\n            : \"bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n      }`}\n    >\n      {state === \"current\" && !reduced && (\n        <span key=\"ring\" aria-hidden=\"true\" className=\"absolute -inset-1 rounded-full shadow-[0_0_0_1px_color-mix(in_oklab,var(--primary)_35%,transparent)] [animation:ui-pop-in_420ms_cubic-bezier(0.34,1.36,0.64,1)_both]\" />\n      )}\n      <span\n        className=\"absolute inset-0 flex items-center justify-center\"\n        style={{ opacity: done ? 0 : 1, scale: done && !reduced ? \"0.6\" : \"1\", filter: done && !reduced ? \"blur(3px)\" : \"none\", transition: reduced ? \"none\" : `opacity 200ms ${EASE_OUT}, scale 320ms ${EASE_OUT}, filter 200ms ${EASE_OUT}` }}\n      >\n        {index + 1}\n      </span>\n      <svg viewBox=\"0 0 16 16\" fill=\"none\" className=\"absolute size-3.5\">\n        <path\n          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          pathLength={1}\n          strokeDasharray={1}\n          style={{ strokeDashoffset: done ? 0 : 1, transition: reduced ? \"none\" : done ? `stroke-dashoffset 360ms ${EASE_OUT} 140ms` : `stroke-dashoffset 160ms ${EASE_OUT}` }}\n        />\n      </svg>\n    </span>\n  );\n}\n\nexport function Steps({ steps, current, onStepClick, orientation = \"horizontal\", className = \"\" }: StepsProps) {\n  const reduced = useInteractionReducedMotion();\n  const at = Math.max(0, Math.min(current, steps.length));\n  const vertical = orientation === \"vertical\";\n  const state = (i: number) => (i < at ? \"done\" : i === at ? \"current\" : \"upcoming\");\n  const fill = (i: number) => (i < at ? 1 : 0);\n\n  const list = (\n    <ol className={`flex ${vertical ? \"flex-col\" : \"w-full items-start\"}`}>\n      {steps.map((step, i) => {\n        const s = state(i);\n        const clickable = !!onStepClick && s === \"done\";\n        const Head = clickable ? \"button\" : \"div\";\n        return (\n          <li key={step.label} aria-current={s === \"current\" ? \"step\" : undefined} className={`relative flex ${vertical ? \"gap-3 pb-6 last:pb-0\" : \"flex-1 flex-col items-center gap-2 text-center\"}`}>\n            {/* The line to the next step: a hairline with a fill that runs along it. */}\n            {i < steps.length - 1 && (\n              <span\n                aria-hidden=\"true\"\n                className={`absolute bg-border ${vertical ? \"left-[13.5px] top-8 bottom-1 w-px\" : \"left-[calc(50%_+_20px)] right-[calc(-50%_+_20px)] top-[13.5px] h-px\"}`}\n              >\n                <span\n                  className={`absolute inset-0 bg-primary ${vertical ? \"origin-top\" : \"origin-left\"}`}\n                  style={{ transform: vertical ? `scaleY(${fill(i)})` : `scaleX(${fill(i)})`, transition: reduced ? \"none\" : `transform 520ms ${i < at ? EASE_OUT : EASE_THROW}` }}\n                />\n              </span>\n            )}\n            <Head\n              {...(clickable ? { type: \"button\" as const, onClick: () => onStepClick(i), \"aria-label\": `Back to ${step.label}` } : {})}\n              className={`relative z-10 rounded-full ${clickable ? `cursor-pointer ${INTERACTION_FOCUS}` : \"\"}`}\n            >\n              <Mark index={i} state={s} reduced={reduced} />\n            </Head>\n            <span className={`flex min-w-0 flex-col gap-0.5 ${vertical ? \"pt-1\" : \"px-1\"}`}>\n              <span className={`text-[13px] font-medium transition-colors duration-300 ${s === \"upcoming\" ? \"text-muted-foreground\" : \"text-foreground\"}`}>{step.label}</span>\n              {step.description && <span className=\"text-[12px] leading-snug text-muted-foreground\">{step.description}</span>}\n            </span>\n          </li>\n        );\n      })}\n    </ol>\n  );\n\n  if (vertical) return <nav aria-label=\"Progress\" className={className}>{list}</nav>;\n\n  const name = steps[Math.min(at, steps.length - 1)]?.label ?? \"\";\n  return (\n    <nav aria-label=\"Progress\" className={`@container w-full ${className}`}>\n      <div className=\"hidden @[24rem]:block\">{list}</div>\n      {/* Narrow: one line and a bar. */}\n      <div className=\"flex flex-col gap-2 @[24rem]:hidden\">\n        <div className=\"flex items-baseline justify-between gap-3 text-[13px]\">\n          <span className=\"tabular-nums text-muted-foreground\">\n            Step <NumberRoll value={Math.min(at + 1, steps.length)} /> of {steps.length}\n          </span>\n          <span className=\"font-medium text-foreground\">\n            <TextMorph>{at >= steps.length ? \"Done\" : name}</TextMorph>\n          </span>\n        </div>\n        <div className=\"h-1 overflow-hidden rounded-full bg-muted\">\n          <div className=\"h-full origin-left rounded-full bg-primary\" style={{ transform: `scaleX(${Math.min(1, (at + (at < steps.length ? 0.5 : 0)) / steps.length)})`, transition: reduced ? \"none\" : `transform 520ms ${EASE_OUT}` }} />\n        </div>\n      </div>\n    </nav>\n  );\n}\n\nexport default Steps;\n"}],"css":{"@keyframes ui-pop-in":{"from":{"opacity":"0","transform":"translateY(-4px) scale(0.96)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/steps. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}