{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "step-flow",
  "type": "registry:component",
  "title": "Step Flow",
  "description": "A three-step sign-up card where the progress bar pours into each step as you reach it, the card grows or shrinks to fit what's next and each field tells you what's missing as you go.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/input.json"
  ],
  "files": [
    {
      "path": "registry/devl/step-flow.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowLeftIcon, ArrowRightIcon, CheckIcon, RotateCcwIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface StepFlowData {\n  name: string;\n  email: string;\n  workspace: string;\n  slug: string;\n  teamSize: string;\n  goals: string[];\n}\n\nexport interface StepFlowProps {\n  takenSlugs?: string[];\n  teamSizes?: string[];\n  goals?: string[];\n  onComplete?: (data: StepFlowData) => void | Promise<void>;\n  className?: string;\n}\n\nconst STEPS = [\"Profile\", \"Workspace\", \"Goals\"] as const;\nconst EMPTY: StepFlowData = { name: \"\", email: \"\", workspace: \"\", slug: \"\", teamSize: \"\", goals: [] };\n\nconst slugify = (s: string) =>\n  s\n    .toLowerCase()\n    .trim()\n    .replace(/[^a-z0-9]+/g, \"-\")\n    .replace(/^-+|-+$/g, \"\")\n    .slice(0, 32);\n\ntype Errors = Partial<Record<keyof StepFlowData, string>>;\n\nfunction validate(step: number, data: StepFlowData, taken: string[]): Errors {\n  const errors: Errors = {};\n  if (step === 0) {\n    if (data.name.trim().length < 2) errors.name = \"Tell us what to call you.\";\n    if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/.test(data.email)) errors.email = \"That email doesn’t look right.\";\n  }\n  if (step === 1) {\n    if (data.workspace.trim().length < 2) errors.workspace = \"Give your workspace a name.\";\n    if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(data.slug)) errors.slug = \"Use 2–32 lowercase letters, numbers or dashes.\";\n    else if (taken.includes(data.slug)) errors.slug = `${data.slug}.devl.app is taken. Try another.`;\n    if (!data.teamSize) errors.teamSize = \"Pick a team size.\";\n  }\n  if (step === 2 && data.goals.length === 0) errors.goals = \"Choose at least one.\";\n  return errors;\n}\n\nexport function StepFlow({\n  takenSlugs = [\"acme\", \"studio\", \"team\"],\n  teamSizes = [\"Just me\", \"2–10\", \"11–50\", \"51+\"],\n  goals: goalOptions = [\"Ship faster\", \"Plan sprints\", \"Track bugs\", \"Write docs\", \"Run standups\", \"Share roadmaps\"],\n  onComplete,\n  className,\n}: StepFlowProps) {\n  const [step, setStep] = useState(0);\n  const [direction, setDirection] = useState(1);\n  const [data, setData] = useState<StepFlowData>(EMPTY);\n  const [touched, setTouched] = useState<Partial<Record<keyof StepFlowData, boolean>>>({});\n  const [slugEdited, setSlugEdited] = useState(false);\n  const [submitting, setSubmitting] = useState(false);\n  const [done, setDone] = useState(false);\n  const reduce = useReducedMotion();\n  const id = useId();\n\n  const errors = validate(step, data, takenSlugs);\n  const shown = (key: keyof StepFlowData) => (touched[key] ? errors[key] : undefined);\n\n  const set = <K extends keyof StepFlowData>(key: K, value: StepFlowData[K]) =>\n    setData((d) => {\n      const next = { ...d, [key]: value };\n      if (key === \"workspace\" && !slugEdited) next.slug = slugify(value as string);\n      return next;\n    });\n\n  const go = (to: number) => {\n    setDirection(to > step ? 1 : -1);\n    setStep(to);\n  };\n\n  const submit = async (event: FormEvent) => {\n    event.preventDefault();\n    if (Object.keys(errors).length) {\n      setTouched((t) => ({ ...t, ...Object.fromEntries(Object.keys(errors).map((k) => [k, true])) }));\n      return;\n    }\n    if (step < STEPS.length - 1) return go(step + 1);\n    setSubmitting(true);\n    await onComplete?.(data);\n    setSubmitting(false);\n    setDirection(1);\n    setDone(true);\n  };\n\n  const reset = () => {\n    setData(EMPTY);\n    setTouched({});\n    setSlugEdited(false);\n    setDone(false);\n    setDirection(-1);\n    setStep(0);\n  };\n\n  const contentRef = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const el = contentRef.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => setHeight(el.offsetHeight));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const stepKey = done ? \"done\" : `step-${step}`;\n  const shownKey = useRef(stepKey);\n  useEffect(() => {\n    if (shownKey.current === stepKey) return;\n    const t = window.setTimeout(() => {\n      shownKey.current = stepKey;\n      const target = contentRef.current\n        ?.querySelector(`[data-step=\"${stepKey}\"]`)\n        ?.querySelector<HTMLElement>(\"input, [role=radio], [aria-pressed], button\");\n      target?.focus({ preventScroll: true });\n    }, 60);\n    return () => clearTimeout(t);\n  }, [stepKey]);\n\n  const field = (key: keyof StepFlowData) => ({\n    id: `${id}-${key}`,\n    \"aria-invalid\": shown(key) ? true : undefined,\n    \"aria-describedby\": shown(key) ? `${id}-${key}-error` : undefined,\n    onBlur: () => setTouched((t) => ({ ...t, [key]: true })),\n  });\n\n  const slide = {\n    enter: (d: number) => ({ x: reduce ? 0 : d * 48, opacity: 0, filter: reduce ? \"none\" : \"blur(4px)\" }),\n    center: { x: 0, opacity: 1, filter: \"blur(0px)\" },\n    exit: (d: number) => ({ x: reduce ? 0 : d * -48, opacity: 0, filter: reduce ? \"none\" : \"blur(4px)\" }),\n  };\n\n  return (\n    <div className={cn(\"w-full max-w-md overflow-hidden rounded-2xl border bg-card shadow-xs/5\", className)}>\n      <div className=\"border-b px-6 pt-5 pb-4\">\n        <StepIndicator current={done ? STEPS.length : step} steps={[...STEPS]} />\n      </div>\n      <motion.div\n        animate={{ height }}\n        className=\"relative overflow-hidden\"\n        initial={false}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 300, damping: 32 }}\n      >\n        <div ref={contentRef}>\n          <AnimatePresence custom={direction} initial={false} mode=\"popLayout\">\n            <motion.div\n              animate=\"center\"\n              custom={direction}\n              data-step={stepKey}\n              exit=\"exit\"\n              initial=\"enter\"\n              key={stepKey}\n              transition={{ type: \"spring\", stiffness: 380, damping: 34 }}\n              variants={slide}\n            >\n              {done ? (\n                <Success data={data} onReset={reset} />\n              ) : (\n                <form className=\"grid gap-5 p-6\" noValidate onSubmit={submit}>\n                  {step === 0 && (\n                    <>\n                      <Heading sub=\"This is how teammates will see you.\">Nice to meet you</Heading>\n                      <Row error={shown(\"name\")} id={`${id}-name`} label=\"Full name\">\n                        <Input\n                          {...field(\"name\")}\n                          autoComplete=\"name\"\n                          onChange={(e) => set(\"name\", e.target.value)}\n                          placeholder=\"Mara Okafor\"\n                          value={data.name}\n                        />\n                      </Row>\n                      <Row error={shown(\"email\")} id={`${id}-email`} label=\"Work email\">\n                        <Input\n                          {...field(\"email\")}\n                          autoComplete=\"email\"\n                          onChange={(e) => set(\"email\", e.target.value)}\n                          placeholder=\"mara@northwind.dev\"\n                          type=\"email\"\n                          value={data.email}\n                        />\n                      </Row>\n                    </>\n                  )}\n                  {step === 1 && (\n                    <>\n                      <Heading sub=\"You can invite people once it’s set up.\">Create your workspace</Heading>\n                      <Row error={shown(\"workspace\")} id={`${id}-workspace`} label=\"Workspace name\">\n                        <Input\n                          {...field(\"workspace\")}\n                          onChange={(e) => set(\"workspace\", e.target.value)}\n                          placeholder=\"Northwind Labs\"\n                          value={data.workspace}\n                        />\n                      </Row>\n                      <Row error={shown(\"slug\")} id={`${id}-slug`} label=\"URL\">\n                        <div className=\"flex w-full items-center gap-2\">\n                          <Input\n                            {...field(\"slug\")}\n                            className=\"font-mono\"\n                            onChange={(e) => {\n                              setSlugEdited(true);\n                              set(\"slug\", e.target.value.toLowerCase());\n                            }}\n                            placeholder=\"northwind\"\n                            value={data.slug}\n                          />\n                          <span className=\"shrink-0 font-mono text-muted-foreground text-xs\">.devl.app</span>\n                        </div>\n                      </Row>\n                      <ChoiceRow\n                        error={shown(\"teamSize\")}\n                        label=\"Team size\"\n                        onToggle={(v) => {\n                          set(\"teamSize\", v);\n                          setTouched((t) => ({ ...t, teamSize: true }));\n                        }}\n                        options={teamSizes}\n                        selected={[data.teamSize]}\n                        single\n                      />\n                    </>\n                  )}\n                  {step === 2 && (\n                    <>\n                      <Heading sub=\"We’ll tailor templates to what you pick.\">What brings you here?</Heading>\n                      <ChoiceRow\n                        error={shown(\"goals\")}\n                        label=\"Goals\"\n                        onToggle={(v) => {\n                          set(\"goals\", data.goals.includes(v) ? data.goals.filter((g) => g !== v) : [...data.goals, v]);\n                          setTouched((t) => ({ ...t, goals: true }));\n                        }}\n                        options={goalOptions}\n                        selected={data.goals}\n                      />\n                    </>\n                  )}\n                  <div className=\"flex items-center justify-between pt-1\">\n                    {step > 0 ? (\n                      <Button onClick={() => go(step - 1)} type=\"button\" variant=\"ghost\">\n                        <ArrowLeftIcon />\n                        Back\n                      </Button>\n                    ) : (\n                      <span className=\"text-muted-foreground text-xs tabular-nums\">\n                        Step {step + 1} of {STEPS.length}\n                      </span>\n                    )}\n                    <Button loading={submitting} type=\"submit\">\n                      {step === STEPS.length - 1 ? \"Create workspace\" : \"Continue\"}\n                      {step < STEPS.length - 1 && <ArrowRightIcon />}\n                    </Button>\n                  </div>\n                </form>\n              )}\n            </motion.div>\n          </AnimatePresence>\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Heading({ children, sub }: { children: ReactNode; sub: string }) {\n  return (\n    <div className=\"grid gap-1\">\n      <h3 className=\"font-heading font-semibold text-base\">{children}</h3>\n      <p className=\"text-muted-foreground text-sm\">{sub}</p>\n    </div>\n  );\n}\n\nfunction ErrorText({ id, error }: { id?: string; error?: string }) {\n  return (\n    <AnimatePresence initial={false}>\n      {error && (\n        <motion.p\n          animate={{ height: \"auto\", opacity: 1 }}\n          className=\"overflow-hidden text-destructive-foreground text-xs\"\n          exit={{ height: 0, opacity: 0 }}\n          id={id}\n          initial={{ height: 0, opacity: 0 }}\n          role=\"alert\"\n          transition={{ type: \"spring\", stiffness: 500, damping: 40 }}\n        >\n          <span className=\"block pt-1.5\">{error}</span>\n        </motion.p>\n      )}\n    </AnimatePresence>\n  );\n}\n\nfunction Row({ id, label, error, children }: { id: string; label: string; error?: string; children: ReactNode }) {\n  return (\n    <div className=\"grid gap-2\">\n      <label className=\"font-medium text-sm\" htmlFor={id}>\n        {label}\n      </label>\n      <div>\n        {children}\n        <ErrorText error={error} id={`${id}-error`} />\n      </div>\n    </div>\n  );\n}\n\nfunction ChoiceRow({\n  label,\n  options,\n  selected,\n  onToggle,\n  single,\n  error,\n}: {\n  label: string;\n  options: string[];\n  selected: string[];\n  onToggle: (value: string) => void;\n  single?: boolean;\n  error?: string;\n}) {\n  return (\n    <fieldset className=\"grid gap-2\">\n      <legend className=\"mb-2 font-medium text-sm\">{label}</legend>\n      <div className=\"flex flex-wrap gap-1.5\" role={single ? \"radiogroup\" : \"group\"}>\n        {options.map((option) => {\n          const on = selected.includes(option);\n          return (\n            <button\n              aria-checked={single ? on : undefined}\n              aria-pressed={single ? undefined : on}\n              className={cn(\n                \"inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 font-medium text-sm outline-none transition-[background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\",\n                on ? \"border-primary bg-primary text-primary-foreground\" : \"bg-background hover:bg-accent dark:bg-input/32\",\n              )}\n              key={option}\n              onClick={() => onToggle(option)}\n              role={single ? \"radio\" : undefined}\n              type=\"button\"\n            >\n              <AnimatePresence initial={false}>\n                {on && !single && (\n                  <motion.span\n                    animate={{ width: 14, opacity: 1 }}\n                    className=\"flex overflow-hidden\"\n                    exit={{ width: 0, opacity: 0 }}\n                    initial={{ width: 0, opacity: 0 }}\n                  >\n                    <CheckIcon className=\"size-3.5 shrink-0\" strokeWidth={3} />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n              {option}\n            </button>\n          );\n        })}\n      </div>\n      <ErrorText error={error} />\n    </fieldset>\n  );\n}\n\nfunction StepIndicator({ steps, current }: { steps: string[]; current: number }) {\n  const reduce = useReducedMotion();\n  const filterId = `goo-steps-${useId().replace(/:/g, \"\")}`;\n  const last = steps.length - 1;\n  const fill = Math.min(current, last) / last;\n  const spring = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 120, damping: 20 };\n  const at = (i: number) => `calc(14px + (100% - 28px) * ${i / last})`;\n\n  return (\n    <div>\n      <div className=\"relative h-7\">\n        <div className=\"absolute inset-x-3.5 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted\" />\n        {steps.map((s, i) => (\n          <span\n            className=\"absolute top-0 size-7 -translate-x-1/2 rounded-full border-2 border-border bg-card\"\n            key={s}\n            style={{ left: at(i) }}\n          />\n        ))}\n        <svg aria-hidden className=\"absolute size-0\">\n          <filter colorInterpolationFilters=\"sRGB\" height=\"300%\" id={filterId} width=\"120%\" x=\"-10%\" y=\"-100%\">\n            <feGaussianBlur in=\"SourceGraphic\" result=\"blur\" stdDeviation=\"3\" />\n            <feColorMatrix in=\"blur\" result=\"goo\" values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7\" />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </svg>\n        <div aria-hidden className=\"absolute inset-0\" style={{ filter: `url(#${filterId})` }}>\n          <div className=\"absolute inset-x-3.5 top-1/2 h-1.5 -translate-y-1/2\">\n            <motion.div animate={{ width: `${fill * 100}%` }} className=\"h-full rounded-full bg-primary\" initial={false} transition={spring} />\n          </div>\n          {steps.map((s, i) => (\n            <motion.span\n              animate={{ scale: i <= current ? 1 : 0.2, opacity: i <= current ? 1 : 0 }}\n              className=\"absolute top-0 size-7 rounded-full bg-primary\"\n              initial={false}\n              key={s}\n              style={{ left: at(i), x: \"-50%\" }}\n              transition={\n                reduce\n                  ? { duration: 0 }\n                  : { type: \"spring\", stiffness: 380, damping: 18, delay: i <= current && i > 0 ? 0.28 : 0 }\n              }\n            />\n          ))}\n        </div>\n        {steps.map((s, i) => (\n          <span\n            className={cn(\n              \"absolute top-0 flex size-7 -translate-x-1/2 items-center justify-center font-medium text-xs tabular-nums transition-colors duration-300\",\n              i <= current ? \"text-primary-foreground\" : \"text-muted-foreground\",\n            )}\n            key={s}\n            style={{ left: at(i) }}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {i < current ? (\n                <motion.span\n                  animate={{ scale: 1, opacity: 1 }}\n                  initial={reduce ? { opacity: 0 } : { scale: 0.6, opacity: 0 }}\n                  key=\"check\"\n                  transition={{ type: \"spring\", stiffness: 500, damping: 28, delay: 0.1 }}\n                >\n                  <CheckIcon className=\"size-3.5\" strokeWidth={3} />\n                </motion.span>\n              ) : (\n                <motion.span animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.6, opacity: 0, transition: { duration: 0.1 } }} initial={false} key=\"n\">\n                  {i + 1}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        ))}\n      </div>\n      <ol className=\"mt-2 grid text-xs\" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>\n        {steps.map((s, i) => (\n          <li\n            aria-current={i === current ? \"step\" : undefined}\n            className={cn(\n              \"transition-colors duration-300\",\n              i === 0 ? \"text-start\" : i === last ? \"text-end\" : \"text-center\",\n              i <= current ? \"font-medium text-foreground\" : \"text-muted-foreground\",\n            )}\n            key={s}\n          >\n            {s}\n          </li>\n        ))}\n      </ol>\n    </div>\n  );\n}\n\nfunction Success({ data, onReset }: { data: StepFlowData; onReset: () => void }) {\n  const reduce = useReducedMotion();\n  const [ready, setReady] = useState(false);\n  useEffect(() => {\n    const t = window.setTimeout(() => setReady(true), 50);\n    return () => clearTimeout(t);\n  }, []);\n\n  return (\n    <div className=\"grid justify-items-center gap-4 p-6 text-center\">\n      <motion.span\n        animate={ready ? { scale: 1, opacity: 1 } : undefined}\n        className=\"flex size-14 items-center justify-center rounded-full bg-success text-white\"\n        initial={reduce ? false : { scale: 0.6, opacity: 0 }}\n        transition={{ type: \"spring\", stiffness: 380, damping: 24 }}\n      >\n        <CheckIcon className=\"size-7\" strokeWidth={3} />\n      </motion.span>\n      <div className=\"grid gap-1\">\n        <h3 className=\"font-heading font-semibold text-base\">You’re in, {data.name.split(\" \")[0]}</h3>\n        <p className=\"text-muted-foreground text-sm\">\n          <span className=\"font-mono text-foreground\">{data.slug}.devl.app</span> is ready for {data.teamSize === \"Just me\" ? \"you\" : `your team of ${data.teamSize}`}.\n        </p>\n      </div>\n      <ul className=\"flex flex-wrap justify-center gap-1.5\">\n        {data.goals.map((g, i) => (\n          <motion.li\n            animate={{ opacity: 1, y: 0 }}\n            className=\"rounded-full bg-muted px-2.5 py-1 text-xs\"\n            initial={reduce ? false : { opacity: 0, y: 8 }}\n            key={g}\n            transition={{ delay: 0.25 + i * 0.05 }}\n          >\n            {g}\n          </motion.li>\n        ))}\n      </ul>\n      <Button onClick={onReset} size=\"sm\" variant=\"ghost\">\n        <RotateCcwIcon />\n        Start over\n      </Button>\n    </div>\n  );\n}\n"
    }
  ]
}