{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"how-it-works","type":"registry:block","title":"How It Works","description":"Numbered steps section whose connector draws from marker to marker as it scrolls into view, lighting each marker as the line arrives; horizontal on wide containers and a vertical timeline on narrow ones, with optional per-step media that cross-fades when a step is hovered, focused or chosen with arrow keys like a tab.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/how-it-works.tsx","type":"registry:component","target":"@components/motion/how-it-works.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/how-it-works\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport {\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n  useCallback,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_IN_OUT, EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface HowItWorksStep {\n  id: string;\n  title: ReactNode;\n  description?: ReactNode;\n  icon?: ReactNode;\n  /** Shown in the media panel while this step is active. */\n  media?: ReactNode;\n}\n\nexport interface HowItWorksProps {\n  /** 3–5 steps read best; more still work. */\n  steps: HowItWorksStep[];\n  eyebrow?: ReactNode;\n  heading?: ReactNode;\n  description?: ReactNode;\n  /** Controlled active step id (only meaningful when steps carry `media`). */\n  value?: string;\n  /** Initial active step id when uncontrolled. Defaults to the first step. */\n  defaultValue?: string;\n  onValueChange?: (id: string) => void;\n  /** Seconds the connector takes to travel between two markers. Default 0.45. */\n  segmentDuration?: number;\n  className?: string;\n  /** Classes for the media panel (aspect ratio, height). */\n  mediaClassName?: string;\n}\n\n// Must match the gap-6 used by the list in both orientations; connector\n// segments span exactly one item plus one gap.\nconst GAP = \"1.5rem\";\n// Marker diameter (size-10).\nconst MARKER = \"2.5rem\";\n\n/**\n * One connector piece between marker `index` and marker `index + 1`. Plain\n * HTML so `scaleX`/`scaleY` grow from a CSS origin motion leaves alone.\n * Two copies render — one per orientation — and container queries pick one.\n */\nfunction Segment({\n  index,\n  drawn,\n  duration,\n  reduce,\n}: {\n  index: number;\n  drawn: boolean;\n  duration: number;\n  reduce: boolean | null;\n}) {\n  const transition = reduce\n    ? { duration: 0.2, delay: index * 0.05 }\n    : { duration, ease: EASE_IN_OUT, delay: 0.15 + index * duration };\n  const vertical: CSSProperties = {\n    top: MARKER,\n    left: `calc(${MARKER} / 2 - 1px)`,\n    height: `calc(100% - ${MARKER} + ${GAP})`,\n  };\n  const horizontal: CSSProperties = {\n    top: `calc(${MARKER} / 2 - 1px)`,\n    left: `calc(50% + ${MARKER} / 2)`,\n    width: `calc(100% - ${MARKER} + ${GAP})`,\n  };\n  return (\n    <>\n      <span\n        aria-hidden\n        style={vertical}\n        className=\"pointer-events-none absolute w-0.5 overflow-hidden rounded-full bg-border @xl:hidden\"\n      >\n        <motion.span\n          className=\"block size-full origin-top bg-primary\"\n          initial={reduce ? { opacity: 0 } : { scaleY: 0 }}\n          animate={drawn ? (reduce ? { opacity: 1 } : { scaleY: 1 }) : undefined}\n          transition={transition}\n        />\n      </span>\n      <span\n        aria-hidden\n        style={horizontal}\n        className=\"pointer-events-none absolute hidden h-0.5 overflow-hidden rounded-full bg-border @xl:block\"\n      >\n        <motion.span\n          className=\"block size-full origin-left bg-primary\"\n          initial={reduce ? { opacity: 0 } : { scaleX: 0 }}\n          animate={drawn ? (reduce ? { opacity: 1 } : { scaleX: 1 }) : undefined}\n          transition={transition}\n        />\n      </span>\n    </>\n  );\n}\n\n/**\n * \"How it works\" section: numbered steps joined by a connector that draws\n * from marker to marker as the section scrolls into view, each marker\n * lighting up as the line reaches it. Steps sit in a row on wide containers\n * and become a vertical timeline on narrow ones. When steps carry `media`,\n * they act as tabs: hovering, focusing or pressing one cross-fades the media\n * panel below to that step.\n */\nexport function HowItWorks({\n  steps,\n  eyebrow,\n  heading,\n  description,\n  value,\n  defaultValue,\n  onValueChange,\n  segmentDuration = 0.45,\n  className,\n  mediaClassName,\n}: HowItWorksProps) {\n  const reduce = useReducedMotion();\n  const listRef = useRef<HTMLOListElement>(null);\n  const drawn = useInView(listRef, { once: true, amount: 0.4 });\n  const baseId = useId();\n  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const hasMedia = steps.some((step) => step.media != null);\n  const [internal, setInternal] = useState(defaultValue ?? steps[0]?.id ?? \"\");\n  const active = value ?? internal;\n\n  const select = useCallback(\n    (id: string) => {\n      if (id === active) return;\n      if (value === undefined) setInternal(id);\n      onValueChange?.(id);\n    },\n    [active, value, onValueChange],\n  );\n\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {\n    const last = steps.length - 1;\n    let next: number | null = null;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n      next = index === last ? 0 : index + 1;\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n      next = index === 0 ? last : index - 1;\n    } else if (event.key === \"Home\") next = 0;\n    else if (event.key === \"End\") next = last;\n    if (next === null) return;\n    event.preventDefault();\n    const step = steps[next];\n    if (!step) return;\n    select(step.id);\n    tabRefs.current[next]?.focus();\n  };\n\n  // Each marker fills when the connector arrives at it.\n  const markerDelay = (index: number) =>\n    reduce ? index * 0.05 : 0.15 + index * segmentDuration;\n\n  const header =\n    eyebrow || heading || description ? (\n      <div className=\"mx-auto mb-10 flex max-w-xl flex-col items-center gap-3 text-center\">\n        {eyebrow ? (\n          <p className=\"text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground\">\n            {eyebrow}\n          </p>\n        ) : null}\n        {heading ? (\n          <h2 className=\"text-balance font-display text-2xl font-semibold leading-tight tracking-tight text-foreground @3xl:text-3xl\">\n            {heading}\n          </h2>\n        ) : null}\n        {description ? (\n          <p className=\"text-pretty text-sm leading-6 text-muted-foreground\">\n            {description}\n          </p>\n        ) : null}\n      </div>\n    ) : null;\n\n  return (\n    <section className={cn(\"@container w-full\", className)}>\n      {header}\n      <ol\n        ref={listRef}\n        role={hasMedia ? \"tablist\" : undefined}\n        className=\"grid grid-cols-1 gap-6 @xl:auto-cols-fr @xl:grid-flow-col\"\n      >\n        {steps.map((step, index) => {\n          const isActive = hasMedia && step.id === active;\n          const marker = (\n            <span className=\"relative grid size-10 shrink-0 place-items-center\">\n              <span className=\"absolute inset-0 rounded-full border border-border bg-card\" />\n              <span className=\"relative font-mono text-sm font-semibold tabular-nums text-muted-foreground\">\n                {index + 1}\n              </span>\n              <motion.span\n                aria-hidden\n                className=\"absolute inset-0 grid place-items-center rounded-full bg-primary font-mono text-sm font-semibold tabular-nums text-primary-foreground shadow-sm\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                animate={\n                  drawn ? (reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }) : undefined\n                }\n                transition={{ duration: 0.35, ease: EASE_OUT, delay: markerDelay(index) }}\n              >\n                {index + 1}\n              </motion.span>\n            </span>\n          );\n          const card = (\n            <span\n              className={cn(\n                \"flex min-w-0 flex-1 flex-col gap-2 rounded-2xl border bg-card p-4 text-left transition-[border-color,background-color,box-shadow] duration-200 @xl:items-center @xl:text-center\",\n                isActive\n                  ? \"border-border-strong bg-muted/60 shadow-sm\"\n                  : \"border-border\",\n                hasMedia && !isActive && \"group-hover/step:border-border-strong\",\n              )}\n            >\n              {step.icon ? (\n                <span className=\"grid size-9 place-items-center rounded-xl bg-primary/10 text-primary [&_svg]:size-4.5\">\n                  {step.icon}\n                </span>\n              ) : null}\n              <span className=\"text-sm font-semibold text-foreground\">{step.title}</span>\n              {step.description ? (\n                <span className=\"text-pretty text-sm leading-6 text-muted-foreground\">\n                  {step.description}\n                </span>\n              ) : null}\n            </span>\n          );\n          const inner = (\n            <>\n              {marker}\n              {card}\n            </>\n          );\n          const itemClass =\n            \"group/step relative flex w-full gap-4 @xl:flex-col @xl:items-center\";\n\n          return (\n            <motion.li\n              key={step.id}\n              role={hasMedia ? \"presentation\" : undefined}\n              className=\"relative\"\n              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}\n              animate={drawn ? { opacity: 1, y: 0 } : undefined}\n              transition={{ duration: 0.45, ease: EASE_OUT, delay: markerDelay(index) }}\n            >\n              {index < steps.length - 1 ? (\n                <Segment\n                  index={index}\n                  drawn={drawn}\n                  duration={segmentDuration}\n                  reduce={reduce}\n                />\n              ) : null}\n              {hasMedia ? (\n                <button\n                  ref={(node) => {\n                    tabRefs.current[index] = node;\n                  }}\n                  type=\"button\"\n                  role=\"tab\"\n                  id={`${baseId}-tab-${step.id}`}\n                  aria-selected={isActive}\n                  aria-controls={`${baseId}-panel`}\n                  tabIndex={isActive ? 0 : -1}\n                  onClick={() => select(step.id)}\n                  onFocus={() => select(step.id)}\n                  onPointerEnter={(event) => {\n                    if (event.pointerType === \"mouse\") select(step.id);\n                  }}\n                  onKeyDown={(event) => onKeyDown(event, index)}\n                  className={cn(\n                    itemClass,\n                    \"rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n                  )}\n                >\n                  {inner}\n                </button>\n              ) : (\n                <div className={itemClass}>{inner}</div>\n              )}\n            </motion.li>\n          );\n        })}\n      </ol>\n\n      {hasMedia ? (\n        <div\n          id={`${baseId}-panel`}\n          role=\"tabpanel\"\n          aria-labelledby={`${baseId}-tab-${active}`}\n          className={cn(\n            \"mt-6 grid overflow-hidden rounded-2xl border border-border bg-card shadow-sm\",\n            mediaClassName,\n          )}\n        >\n          {/* Every media stays mounted in one grid cell, so the panel keeps\n              the tallest step's height and switching never shifts layout. */}\n          {steps.map((step) => {\n            const shown = step.id === active;\n            return (\n              <motion.div\n                key={step.id}\n                aria-hidden={!shown}\n                inert={!shown}\n                className=\"[grid-area:1/1]\"\n                initial={false}\n                animate={\n                  reduce\n                    ? { opacity: shown ? 1 : 0 }\n                    : { opacity: shown ? 1 : 0, scale: shown ? 1 : 0.98, y: shown ? 0 : 6 }\n                }\n                transition={{ duration: shown ? 0.3 : 0.18, ease: EASE_OUT }}\n              >\n                {step.media}\n              </motion.div>\n            );\n          })}\n        </div>\n      ) : null}\n    </section>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}