{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "speed-dial",
  "type": "registry:ui",
  "title": "Speed Dial",
  "description": "A floating button that fans out labeled actions, nearest first, on a spring.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/speed-dial.tsx",
      "content": "\"use client\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { AnimatePresence, motion, useReducedMotion, type Variants } from \"motion/react\";\nimport { createContext, useContext } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nconst ReduceContext = createContext(false);\n\nexport type SpeedDialProps = {\n  /** What the button does, e.g. \"Create\". Its accessible name, and a hint beside it on hover. */\n  label: string;\n  /** The actions, as SpeedDialAction elements, listed top to bottom. */\n  children: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Where the button sits. `fixed` pins it to the viewport's bottom-right corner above the\n   * safe area; `absolute` to the nearest positioned ancestor's; `inline` leaves it in the flow.\n   */\n  placement?: \"fixed\" | \"absolute\" | \"inline\";\n  /** Render the actions into this element instead of document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  /** The glyph on the button. Rotates 45° when open, so a plus becomes a cross. */\n  icon?: React.ReactNode;\n  /** Dim what's behind the actions so their labels stay readable. */\n  scrim?: boolean;\n  className?: string;\n  disabled?: boolean;\n};\n\nexport function SpeedDial({\n  label,\n  children,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  placement = \"fixed\",\n  container,\n  icon,\n  scrim = true,\n  className,\n  disabled,\n}: SpeedDialProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const reduce = !!useReducedMotion();\n\n  // Nearest action first on the way out, a quicker reverse on the way back.\n  const list: Variants = {\n    open: { transition: { staggerChildren: reduce ? 0 : 0.035, staggerDirection: -1 } },\n    closed: { transition: { staggerChildren: reduce ? 0 : 0.02, staggerDirection: 1 } },\n  };\n\n  return (\n    <ReduceContext.Provider value={reduce}>\n      <Menu.Root open={open} onOpenChange={(next) => setOpen(next)} modal={false} disabled={disabled}>\n        <div\n          className={cn(\n            \"group/dial isolate\",\n            // Above the scrim, so the button that closes the dial is never dimmed.\n            placement === \"fixed\" && \"fixed bottom-[max(24px,env(safe-area-inset-bottom))] right-6 z-(--z-dropdown)\",\n            placement === \"absolute\" && \"absolute bottom-4 right-4 z-(--z-dropdown)\",\n            placement === \"inline\" && \"relative z-(--z-dropdown) inline-flex\",\n            className,\n          )}\n        >\n          <Menu.Trigger\n            aria-label={label}\n            className={cn(\n              \"relative grid size-12 place-items-center rounded-full bg-fg text-frame shadow-pop outline-none\",\n              \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/90 active:scale-[0.94] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n            )}\n          >\n            <motion.span\n              aria-hidden\n              className=\"grid place-items-center\"\n              initial={false}\n              animate={{ rotate: open ? 45 : 0 }}\n              transition={reduce ? { duration: 0 } : spring.snappy}\n            >\n              {icon ?? (\n                <svg width=\"20\" height=\"20\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\">\n                  <path d=\"M8 3.5v9M3.5 8h9\" />\n                </svg>\n              )}\n            </motion.span>\n          </Menu.Trigger>\n          {/* A quiet name beside the button on hover; the button already carries it as its label. */}\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 translate-x-1 whitespace-nowrap rounded-md border border-line-2 bg-raised px-2 py-1 text-[12px] font-medium text-fg opacity-0 shadow-pop\",\n              \"transition-[opacity,translate] duration-150 ease-out\",\n              !open && \"group-hover/dial:translate-x-0 group-hover/dial:opacity-100 group-hover/dial:delay-300\",\n            )}\n          >\n            {label}\n          </span>\n        </div>\n\n        <AnimatePresence>\n          {open && (\n            <Menu.Portal keepMounted container={container}>\n              {scrim && (\n                <Menu.Backdrop\n                  className={cn(container ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-sticky) bg-frame/80\")}\n                  render={<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: 0.14 } }} transition={{ duration: 0.2, ease: ease.out }} />}\n                />\n              )}\n              <Menu.Positioner side=\"top\" align=\"end\" sideOffset={12} className=\"z-(--z-dropdown) outline-none\">\n                <Menu.Popup\n                  className=\"flex flex-col items-end gap-2.5 pr-1 outline-none\"\n                  render={<motion.div variants={list} initial=\"closed\" animate=\"open\" exit=\"closed\" />}\n                >\n                  {children}\n                </Menu.Popup>\n              </Menu.Positioner>\n            </Menu.Portal>\n          )}\n        </AnimatePresence>\n      </Menu.Root>\n    </ReduceContext.Provider>\n  );\n}\n\nexport type SpeedDialActionProps = {\n  /** The action's name, shown in a pill beside its button. */\n  children: React.ReactNode;\n  icon: React.ReactNode;\n  onClick?: (event: React.MouseEvent<HTMLElement>) => void;\n  disabled?: boolean;\n  /** Keep the dial open after this action, e.g. for toggles. */\n  closeOnClick?: boolean;\n  className?: string;\n};\n\nexport function SpeedDialAction({ children, icon, onClick, disabled, closeOnClick = true, className }: SpeedDialActionProps) {\n  const reduce = useContext(ReduceContext);\n  const row: Variants = reduce\n    ? { closed: { opacity: 0, transition: { duration: 0.1 } }, open: { opacity: 1, transition: { duration: 0.15 } } }\n    : {\n        closed: { opacity: 0, y: 10, scale: 0.8, transition: { duration: 0.12, ease: ease.in } },\n        open: { opacity: 1, y: 0, scale: 1, transition: spring.snappy },\n      };\n  // The label trails its button slightly, sliding in from the button's side.\n  const pill: Variants = reduce\n    ? { closed: {}, open: {} }\n    : { closed: { opacity: 0, x: 8, transition: { duration: 0.1 } }, open: { opacity: 1, x: 0, transition: { ...spring.snappy, delay: 0.04 } } };\n\n  return (\n    <Menu.Item\n      disabled={disabled}\n      closeOnClick={closeOnClick}\n      onClick={onClick}\n      render={<motion.div variants={row} style={{ originX: 1, originY: 1 }} />}\n      className={cn(\n        \"group/action flex cursor-default select-none items-center gap-3 outline-none\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        className,\n      )}\n    >\n      <motion.span\n        variants={pill}\n        className={cn(\n          \"whitespace-nowrap rounded-lg border border-line-2 bg-raised px-2.5 py-1.5 text-[12.5px] font-medium leading-none text-fg-2 shadow-pop\",\n          \"transition-[color,border-color] duration-150\",\n          \"group-data-highlighted/action:border-fg-4 group-data-highlighted/action:text-fg\",\n        )}\n      >\n        {children}\n      </motion.span>\n      <span\n        className={cn(\n          \"grid size-10 shrink-0 place-items-center rounded-full border border-line-2 bg-raised text-fg-2 shadow-pop\",\n          \"transition-[background-color,color,border-color,scale] duration-150 ease-out group-active/action:scale-[0.92]\",\n          \"group-data-highlighted/action:border-fg-4 group-data-highlighted/action:bg-hover group-data-highlighted/action:text-fg\",\n        )}\n      >\n        {icon}\n      </span>\n    </Menu.Item>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/speed-dial"
}
