{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "speed-dial",
  "type": "registry:ui",
  "title": "Speed Dial",
  "description": "A floating action button that fans out into labelled actions with staggered springs, as a stack or an arc.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/speed-dial.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nexport interface SpeedDialAction {\n  /** Stable, unique id. */\n  id: string;\n  /** Visible label and accessible name. */\n  label: string;\n  /** Icon inside the action button (decorative; the label names it). */\n  icon: React.ReactNode;\n  /** Runs when the action is chosen. */\n  onSelect?: () => void;\n  disabled?: boolean;\n}\n\ntype Spring = { stiffness: number; damping: number; mass: number };\ntype Direction = \"up\" | \"down\" | \"left\" | \"right\";\ntype Corner = \"bottom-right\" | \"bottom-left\" | \"top-right\" | \"top-left\";\n\nexport interface SpeedDialProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onSelect\"> {\n  actions: SpeedDialAction[];\n  /** Which way the actions open. The first action sits closest to the main button. */\n  direction?: Direction;\n  /** A straight stack, or a quarter-circle arc that sweeps from `direction` towards the inside of `corner`. */\n  layout?: \"stack\" | \"arc\";\n  /** \"fixed\" pins to the viewport, \"absolute\" to the nearest positioned parent, \"static\" stays in flow. */\n  position?: \"fixed\" | \"absolute\" | \"static\";\n  /** Corner used by fixed / absolute positioning; it also decides which side labels and arcs go to. */\n  corner?: Corner;\n  /** Distance from the corner in px (fixed / absolute only). */\n  offset?: number;\n  /** Show text labels beside the actions. Horizontal stacks show them on hover / focus only. */\n  showLabels?: boolean;\n  /** Space between actions in px. */\n  gap?: number;\n  /** Delay between actions as they open, in ms. Closing runs in reverse at half the delay. */\n  stagger?: number;\n  /** Spring for the actions and the main icon. */\n  spring?: Spring;\n  /** Degrees the main icon turns when open (45 turns a plus into a close cross). */\n  rotation?: number;\n  /** Main icon. Defaults to a plus. */\n  icon?: React.ReactNode;\n  /** Main button fill and focus ring colour. */\n  accent?: string;\n  /** Accessible name of the main button and the menu. */\n  label?: string;\n  /** Open state (controlled). */\n  open?: boolean;\n  /** Initial open state (uncontrolled). Focus is not moved on mount. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Called after any action is chosen (in addition to the action's own onSelect). */\n  onAction?: (action: SpeedDialAction) => void;\n}\n\nconst FAB = 56;\nconst ITEM = 44;\nconst ANGLE: Record<Direction, number> = { up: -90, down: 90, left: 180, right: 0 };\n/** Keys that move away from / back towards the main button, per direction. */\nconst AWAY: Record<Direction, string> = { up: \"ArrowUp\", down: \"ArrowDown\", left: \"ArrowLeft\", right: \"ArrowRight\" };\nconst TOWARD: Record<Direction, string> = { up: \"ArrowDown\", down: \"ArrowUp\", left: \"ArrowRight\", right: \"ArrowLeft\" };\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nfunction PlusIcon() {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" aria-hidden=\"true\">\n      <path d=\"M12 5v14M5 12h14\" />\n    </svg>\n  );\n}\n\n/** Where each action sits relative to the centre of the main button, and which way its label points. */\nfunction layoutActions(count: number, direction: Direction, layout: \"stack\" | \"arc\", corner: Corner, gap: number) {\n  const right = corner.endsWith(\"right\");\n  const bottom = corner.startsWith(\"bottom\");\n  const vertical = direction === \"up\" || direction === \"down\";\n  if (layout === \"stack\") {\n    const rad = (ANGLE[direction] * Math.PI) / 180;\n    const [ux, uy] = [Math.round(Math.cos(rad)), Math.round(Math.sin(rad))];\n    // Labels go towards the inside of the corner: beside vertical stacks, above / below horizontal ones.\n    const label = vertical ? ([right ? -1 : 1, 0] as const) : ([0, bottom ? -1 : 1] as const);\n    return Array.from({ length: count }, (_, i) => {\n      const d = FAB / 2 + gap + ITEM / 2 + i * (ITEM + gap);\n      return { x: ux * d, y: uy * d, lx: label[0], ly: label[1] };\n    });\n  }\n  // Arc: sweep 90° from `direction` towards the inside of the corner.\n  const sweepToward = vertical ? (right ? 180 : 0) : bottom ? -90 : 90;\n  const base = ANGLE[direction];\n  const sign = Math.sign(((sweepToward - base + 540) % 360) - 180) || 1;\n  const step = count > 1 ? 90 / (count - 1) : 0;\n  // Radius large enough that neighbouring actions never touch.\n  const minChord = ITEM + gap;\n  const radius = Math.max(FAB / 2 + gap + ITEM / 2 + 16, step ? minChord / (2 * Math.sin(((step / 2) * Math.PI) / 180)) : 0);\n  return Array.from({ length: count }, (_, i) => {\n    const rad = ((base + sign * i * step) * Math.PI) / 180;\n    const [cx, cy] = [Math.cos(rad), Math.sin(rad)];\n    return { x: round(cx * radius), y: round(cy * radius), lx: round(cx), ly: round(cy) };\n  });\n}\n\n/**\n * A floating action button that fans out into labelled secondary actions with staggered springs.\n * Implements the menu button pattern: aria-expanded trigger, role=\"menu\" with role=\"menuitem\" actions,\n * arrow-key navigation, Escape to close (focus returns to the trigger) and click-outside dismissal.\n */\nexport function SpeedDial({\n  actions,\n  direction = \"up\",\n  layout = \"stack\",\n  position = \"fixed\",\n  corner = \"bottom-right\",\n  offset = 24,\n  showLabels = true,\n  gap = 12,\n  stagger = 40,\n  spring = { stiffness: 480, damping: 30, mass: 0.8 },\n  rotation = 45,\n  icon,\n  accent = \"#FF7448\",\n  label = \"Quick actions\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  onAction,\n  className = \"\",\n  style,\n  ...props\n}: SpeedDialProps) {\n  const uid = React.useId();\n  const menuId = `${uid}-menu`;\n  const triggerId = `${uid}-trigger`;\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const itemRefs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  /** Index to focus once the actions have mounted (set only by user interaction). */\n  const focusOnOpen = React.useRef<number | null>(null);\n\n  const [internalOpen, setInternalOpen] = React.useState(defaultOpen);\n  const isOpen = openProp ?? internalOpen;\n  const count = actions.length;\n  const positions = layoutActions(count, direction, layout, corner, gap);\n  const horizontalStack = layout === \"stack\" && (direction === \"left\" || direction === \"right\");\n\n  const setOpen = (next: boolean) => {\n    if (openProp === undefined) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n\n  const enabledIndexes = actions.flatMap((a, i) => (a.disabled ? [] : [i]));\n  const firstEnabled = enabledIndexes[0] ?? -1;\n  const lastEnabled = enabledIndexes[enabledIndexes.length - 1] ?? -1;\n\n  const openMenu = (focusIndex: number) => {\n    focusOnOpen.current = focusIndex;\n    setOpen(true);\n  };\n\n  const close = (returnFocus: boolean) => {\n    setOpen(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const choose = (action: SpeedDialAction) => {\n    if (action.disabled) return;\n    action.onSelect?.();\n    onAction?.(action);\n    close(true);\n  };\n\n  // Move focus into the menu once the actions exist (only after a user opened it).\n  React.useEffect(() => {\n    if (!isOpen || focusOnOpen.current === null) return;\n    const index = focusOnOpen.current;\n    focusOnOpen.current = null;\n    if (index >= 0) itemRefs.current[index]?.focus({ preventScroll: true });\n  }, [isOpen]);\n\n  const outsidePointer = React.useEffectEvent((event: PointerEvent) => {\n    if (rootRef.current?.contains(event.target as Node)) return;\n    close(false);\n  });\n\n  React.useEffect(() => {\n    if (!isOpen) return;\n    const onDown = (event: PointerEvent) => outsidePointer(event);\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [isOpen]);\n\n  const step = (from: number, delta: 1 | -1) => {\n    for (let i = 1; i <= count; i++) {\n      const next = (from + delta * i + count * 2) % count;\n      if (!actions[next].disabled) return next;\n    }\n    return from;\n  };\n\n  const handleMenuKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => {\n    const current = itemRefs.current.findIndex((el) => el === document.activeElement);\n    let next: number | null = null;\n    if (event.key === AWAY[direction]) next = step(current, 1);\n    else if (event.key === TOWARD[direction]) next = step(current, -1);\n    else if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") next = step(current, 1);\n    else if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") next = step(current, -1);\n    else if (event.key === \"Home\") next = firstEnabled;\n    else if (event.key === \"End\") next = lastEnabled;\n    else if (event.key === \"Escape\") {\n      event.preventDefault();\n      event.stopPropagation();\n      close(true);\n      return;\n    } else if (event.key === \"Tab\") {\n      // Leaving the menu closes it; focus continues to the next element naturally.\n      setOpen(false);\n      return;\n    }\n    if (next === null || next < 0) return;\n    event.preventDefault();\n    itemRefs.current[next]?.focus({ preventScroll: true });\n  };\n\n  const handleTriggerKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {\n    if (event.key === \"ArrowUp\" || event.key === \"ArrowDown\" || event.key === \"ArrowLeft\" || event.key === \"ArrowRight\") {\n      event.preventDefault();\n      openMenu(event.key === TOWARD[direction] ? lastEnabled : firstEnabled);\n    } else if (event.key === \"Escape\" && isOpen) {\n      event.preventDefault();\n      close(true);\n    }\n  };\n\n  const handleTriggerClick = () => {\n    if (isOpen) close(false);\n    else openMenu(firstEnabled);\n  };\n\n  const springTransition = reduceMotion ? { duration: 0 } : { type: \"spring\" as const, ...spring };\n  const fade = reduceMotion ? { duration: 0 } : { duration: 0.16 };\n\n  const right = corner.endsWith(\"right\");\n  const bottom = corner.startsWith(\"bottom\");\n  const placement: React.CSSProperties =\n    position === \"static\"\n      ? {}\n      : { [bottom ? \"bottom\" : \"top\"]: offset, [right ? \"right\" : \"left\"]: offset };\n\n  return (\n    <div\n      ref={rootRef}\n      className={`${position === \"fixed\" ? \"fixed z-40\" : position === \"absolute\" ? \"absolute z-10\" : \"relative inline-block\"} ${className}`}\n      style={{ [\"--sd-accent\" as string]: accent, width: FAB, height: FAB, ...placement, ...style }}\n      {...props}\n    >\n      <ul\n        id={menuId}\n        role={isOpen ? \"menu\" : undefined}\n        aria-labelledby={isOpen ? triggerId : undefined}\n        aria-orientation={isOpen ? (direction === \"up\" || direction === \"down\" ? \"vertical\" : \"horizontal\") : undefined}\n        aria-hidden={isOpen ? undefined : true}\n        onKeyDown={handleMenuKeyDown}\n        className={`absolute left-1/2 top-1/2 m-0 list-none p-0 ${isOpen ? \"\" : \"pointer-events-none\"}`}\n      >\n        <AnimatePresence>\n          {isOpen &&\n            actions.map((action, i) => {\n              const p = positions[i];\n              const labelShift = ITEM / 2 + 10;\n              return (\n                <motion.li\n                  key={action.id}\n                  role=\"none\"\n                  className=\"group absolute\"\n                  style={{ left: -ITEM / 2, top: -ITEM / 2, width: ITEM, height: ITEM }}\n                  initial={reduceMotion ? { opacity: 0 } : { x: 0, y: 0, scale: 0.4, opacity: 0 }}\n                  animate={{\n                    x: p.x,\n                    y: p.y,\n                    scale: 1,\n                    opacity: 1,\n                    transition: { default: { ...springTransition, delay: reduceMotion ? 0 : (i * stagger) / 1000 }, opacity: fade },\n                  }}\n                  exit={{\n                    x: reduceMotion ? p.x : 0,\n                    y: reduceMotion ? p.y : 0,\n                    scale: reduceMotion ? 1 : 0.4,\n                    opacity: 0,\n                    transition: {\n                      default: { ...springTransition, delay: reduceMotion ? 0 : ((count - 1 - i) * stagger) / 2000 },\n                      opacity: reduceMotion ? fade : { duration: 0.14, delay: ((count - 1 - i) * stagger) / 2000 + 0.06 },\n                    },\n                  }}\n                >\n                  <button\n                    ref={(el) => {\n                      itemRefs.current[i] = el;\n                    }}\n                    type=\"button\"\n                    role=\"menuitem\"\n                    tabIndex={-1}\n                    disabled={action.disabled}\n                    aria-disabled={action.disabled || undefined}\n                    onClick={() => choose(action)}\n                    className=\"relative grid size-full place-items-center rounded-full border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgb(0_0_0/0.06),0_6px_16px_-6px_rgb(0_0_0/0.18)] outline-hidden transition-colors duration-150 hover:bg-zinc-50 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)] disabled:cursor-not-allowed disabled:opacity-45 dark:border-white/10 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:hover:text-white [&_svg]:size-[18px]\"\n                  >\n                    <span aria-hidden=\"true\" className=\"inline-flex\">\n                      {action.icon}\n                    </span>\n                    {/* The label lives inside the menuitem so it is the accessible name, then sits beside it visually. */}\n                    <span\n                      className={\n                        showLabels\n                          ? `pointer-events-none absolute whitespace-nowrap rounded-md border border-zinc-900/10 bg-white/95 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-700 shadow-sm dark:border-white/10 dark:bg-zinc-900/95 dark:text-zinc-200 ${\n                              horizontalStack\n                                ? \"opacity-0 transition-opacity duration-150 group-hover:opacity-100 group-has-[:focus-visible]:opacity-100 motion-reduce:transition-none\"\n                                : \"\"\n                            }`\n                          : \"sr-only\"\n                      }\n                      style={\n                        showLabels\n                          ? {\n                              left: `calc(50% + ${round(p.lx * labelShift)}px)`,\n                              top: `calc(50% + ${round(p.ly * labelShift)}px)`,\n                              transform: `translate(${round(-50 + p.lx * 50)}%, ${round(-50 + p.ly * 50)}%)`,\n                            }\n                          : undefined\n                      }\n                    >\n                      {action.label}\n                    </span>\n                  </button>\n                </motion.li>\n              );\n            })}\n        </AnimatePresence>\n      </ul>\n\n      <button\n        ref={triggerRef}\n        id={triggerId}\n        type=\"button\"\n        aria-label={label}\n        aria-haspopup=\"menu\"\n        aria-expanded={isOpen}\n        aria-controls={menuId}\n        onClick={handleTriggerClick}\n        onKeyDown={handleTriggerKeyDown}\n        className=\"relative grid size-full place-items-center rounded-full bg-[var(--sd-accent)] text-zinc-950 shadow-[0_1px_2px_rgb(0_0_0/0.12),0_10px_24px_-8px_rgb(0_0_0/0.35)] outline-hidden transition-transform duration-150 hover:scale-[1.04] focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-solid focus-visible:outline-[var(--sd-accent)] active:scale-95 motion-reduce:transition-none [&_svg]:size-6\"\n      >\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"inline-flex\"\n          initial={false}\n          animate={{ rotate: isOpen ? rotation : 0 }}\n          transition={springTransition}\n        >\n          {icon ?? <PlusIcon />}\n        </motion.span>\n      </button>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/speed-dial"
}
