{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "icon-button",
  "type": "registry:ui",
  "title": "Icon Button",
  "description": "Square button with a required label, warm tooltips and a sprung squash.",
  "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"
  ],
  "files": [
    {
      "path": "components/ui/icon-button.tsx",
      "content": "\"use client\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/cn\";\nimport { spring, swap } from \"@/lib/motion\";\n\nexport type IconButtonSize = \"sm\" | \"md\" | \"lg\";\nexport type IconButtonVariant = \"ghost\" | \"secondary\";\nexport type TooltipSide = \"top\" | \"bottom\" | \"left\" | \"right\";\n\nconst sizes: Record<IconButtonSize, string> = {\n  sm: \"size-7 rounded-md [&_svg]:size-3.5\",\n  md: \"size-8 rounded-lg [&_svg]:size-4\",\n  lg: \"size-9 rounded-lg [&_svg]:size-4\",\n};\n\n// Inside a ButtonGroup the button becomes a flat segment: the group draws the\n// border, shadow, hairlines and the gliding hover wash, and only the icon squashes.\nconst segment =\n  \"in-data-[slot=button-group]:rounded-none in-data-[slot=button-group]:border-0 in-data-[slot=button-group]:bg-transparent in-data-[slot=button-group]:shadow-none in-data-[slot=button-group]:hover:bg-transparent in-data-[slot=button-group]:data-popup-open:bg-transparent in-data-[slot=button-group]:active:scale-100 in-data-[slot=button-group]:focus-visible:-outline-offset-3 in-data-[slot=button-group]:focus-visible:rounded-[5px]\" + \" in-data-[slot=button-group]:active:bg-transparent in-data-[slot=button-group]:transform-none!\";\n\nconst variants: Record<IconButtonVariant, string> = {\n  ghost: \"text-fg-2 hover:bg-hover hover:text-fg active:bg-fg/[0.08] data-popup-open:bg-hover data-popup-open:text-fg \" + segment,\n  secondary:\n    \"border border-line-2 bg-raised text-fg-2 shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover hover:text-fg active:bg-fg/[0.08] data-popup-open:border-fg-4 data-popup-open:bg-hover data-popup-open:text-fg \" + segment,\n};\n\n/**\n * Wrap a toolbar in this so its tooltips share one warm-up: the first waits\n * ~500ms, the next ones open instantly while the pointer stays in the group.\n */\nexport function IconButtonProvider({ delay = 500, children }: { delay?: number; children: React.ReactNode }) {\n  return (\n    <Tooltip.Provider delay={delay} closeDelay={0} timeout={400}>\n      {children}\n    </Tooltip.Provider>\n  );\n}\n\n/** A shortcut as keycaps, split on spaces: \"⌘ B\", \"Shift D\". */\nexport function Keys({ keys, className }: { keys: string; className?: string }) {\n  return (\n    <kbd className={cn(\"inline-flex items-center gap-0.5 font-sans\", className)}>\n      {keys.split(\" \").map((k, i) => (\n        <span\n          key={i}\n          className=\"grid h-4 min-w-4 place-items-center rounded-[4px] border border-line-2 px-1 font-mono text-[10px] leading-none text-fg-3\"\n        >\n          {k}\n        </span>\n      ))}\n    </kbd>\n  );\n}\n\nexport type ActionTooltipProps = {\n  /** What the control does. */\n  content: React.ReactNode;\n  shortcut?: string;\n  side?: TooltipSide;\n  /** Milliseconds before opening. Defaults to the provider's, or 600 without one. */\n  delay?: number;\n  disabled?: boolean;\n  /** The trigger. It receives the tooltip's props and ref. */\n  children: React.ReactElement;\n};\n\n/**\n * The small tooltip every icon-only control carries: grows 4% from the side it's\n * on, never covers its trigger, and is skipped on touch screens.\n */\nexport function ActionTooltip({ content, shortcut, side = \"top\", delay, disabled, children }: ActionTooltipProps) {\n  return (\n    <Tooltip.Root disabled={disabled}>\n      <Tooltip.Trigger delay={delay} render={children} />\n      <Tooltip.Portal>\n        <Tooltip.Positioner side={side} sideOffset={6} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex min-h-6 max-w-64 items-center gap-2 text-balance rounded-md border border-line-2 bg-raised py-1 pl-2 text-[12px] leading-4 text-fg shadow-pop\",\n              shortcut ? \"pr-1\" : \"pr-2\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-150 ease-out-expo\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"data-[side=top]:data-starting-style:translate-y-0.5 data-[side=bottom]:data-starting-style:-translate-y-0.5\",\n              \"data-[side=left]:data-starting-style:translate-x-0.5 data-[side=right]:data-starting-style:-translate-x-0.5\",\n              // Moving between warm siblings, or opening from the keyboard, is instant.\n              \"data-instant:duration-0\",\n            )}\n          >\n            {content}\n            {shortcut && <Keys keys={shortcut} />}\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n\nexport type IconButtonProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"aria-label\"> & {\n  /** What the button does (\"Copy link\", not \"Clipboard\"). Its accessible name and its tooltip. */\n  label: string;\n  /** The icon. */\n  children: React.ReactNode;\n  size?: IconButtonSize;\n  variant?: IconButtonVariant;\n  /**\n   * The tooltip on hover and keyboard focus. `true` shows the label; pass text to\n   * say less than the label when the row already names the object (\"Download\"\n   * for a button labeled \"Download q3-forecast.xlsx\"); `false` for none.\n   */\n  tooltip?: boolean | React.ReactNode;\n  /** A shortcut shown in the tooltip, as space-separated keys: \"⌘ D\". */\n  shortcut?: string;\n  tooltipSide?: TooltipSide;\n  /** Swaps the icon for a spinner and ignores presses, keeping focus. */\n  loading?: boolean;\n};\n\nexport function IconButton({\n  label,\n  children,\n  size = \"md\",\n  variant = \"ghost\",\n  tooltip = true,\n  shortcut,\n  tooltipSide = \"top\",\n  loading = false,\n  disabled,\n  className,\n  onClick,\n  ref,\n  ...rest\n}: IconButtonProps) {\n  const reduce = useReducedMotion();\n\n  const button = (\n    <motion.button\n      ref={ref}\n      type=\"button\"\n      aria-label={label}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || undefined}\n      disabled={disabled}\n      data-size={size}\n      data-variant={variant}\n      data-busy={loading ? \"\" : undefined}\n      // The squash: a quick press to 92%, then a spring back with a pixel of overshoot.\n      // Always an object: Motion adds tabindex for tap gestures, and server and client must agree.\n      whileTap={loading || disabled || reduce ? {} : { scale: 0.92, transition: { duration: 0.08 } }}\n      transition={spring.bouncy}\n      onClick={(e) => {\n        if (loading) return e.preventDefault();\n        onClick?.(e);\n      }}\n      className={cn(\n        \"group/icon relative inline-grid shrink-0 select-none place-items-center outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n        \"transition-[background-color,border-color,color] duration-150 ease-out-quart\",\n        \"disabled:pointer-events-none disabled:opacity-50 data-busy:cursor-progress\",\n        // 44px to a finger, whatever it draws at.\n        \"pointer-coarse:after:absolute pointer-coarse:after:left-1/2 pointer-coarse:after:top-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2\",\n        sizes[size],\n        variants[variant],\n        className,\n      )}\n      {...(rest as React.ComponentProps<typeof motion.button>)}\n    >\n      <AnimatePresence initial={false}>\n        <motion.span\n          key={loading ? \"busy\" : \"icon\"}\n          aria-hidden\n          className=\"col-start-1 row-start-1 grid place-items-center transition-[scale] duration-100 ease-out-quart in-data-[slot=button-group]:group-active/icon:scale-[0.86]\"\n          initial={reduce ? { opacity: 0 } : swap.initial}\n          // Same resting style either way, so server and client markup agree.\n          animate={swap.animate}\n          exit={reduce ? { opacity: 0 } : swap.exit}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {loading ? <Spinner /> : children}\n        </motion.span>\n      </AnimatePresence>\n    </motion.button>\n  );\n\n  if (tooltip === false || tooltip == null) return button;\n  return (\n    <ActionTooltip content={tooltip === true ? label : tooltip} shortcut={shortcut} side={tooltipSide} disabled={disabled}>\n      {button}\n    </ActionTooltip>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden className=\"animate-[spin_0.7s_linear_infinite]\">\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" stroke=\"currentColor\" strokeOpacity=\"0.22\" strokeWidth=\"1.5\" />\n      <path d=\"M8 2.25A5.75 5.75 0 0 1 13.75 8\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/icon-button"
}
