{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "button",
  "title": "Button",
  "description": "Versatile button with variants, sizes, loading state, and icon support.",
  "dependencies": [
    "@base-ui/react",
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "https://www.deltacomponents.dev/r/utils.json",
    "https://www.deltacomponents.dev/r/icon-context.json",
    "https://www.deltacomponents.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/ui/button.tsx",
      "content": "\"use client\";\n\nimport {\n  cloneElement,\n  forwardRef,\n  isValidElement,\n  type ButtonHTMLAttributes,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\nimport { Button as ButtonPrimitive } from \"@base-ui/react/button\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport type { IconComponent } from \"@/lib/icon-context\";\nimport { cn } from \"@/lib/utils\";\n\n// The size ladder shared by every control — sm 32px, default 36px, lg 40px,\n// shadcn's heights — rides `--control-*` custom properties instead of literal\n// classes, so one set of classes serves every scope. `:root` carries the\n// values; `[data-size=\"compact\"]` shifts the ladder down a step, either from an\n// ancestor (e.g. a docs-only SizeProvider) or from this component's own root\n// (see `resolvedSize` below), so a button resolves the right size whether it's\n// driven by an explicit prop or by whatever scope it's rendered in — with no\n// context import required. `compact` is sm inside that scope: still 28px.\nconst controlSize = (h: string, px: string, text = \"text-[length:var(--control-text,13px)]\") =>\n  `${h} ${px} ${text} gap-[var(--control-gap,6px)]`;\nconst iconOnlySize = (box: string, glyph = \"[&_svg]:size-[var(--control-icon-glyph,16px)]\") =>\n  `${box} p-0 ${glyph}`;\n\nconst buttonVariants = cva(\n  [\n    \"group relative isolate inline-flex items-center justify-center outline-none cursor-pointer font-medium\",\n    \"rounded-[var(--radius-button,var(--radius,0.5rem))]\",\n    // The colour still lands in 80ms; the press dips the whole button — label,\n    // icon and ground together — on the moderate tier and releases a tier\n    // quicker, the same shape as Image's press. 0.96, never less: below that\n    // a control this small reads as flinching.\n    \"transition-[color,scale] [transition-duration:80ms,var(--motion-moderate-exit)] ease-spring\",\n    \"active:scale-[0.96] active:[transition-duration:80ms,var(--motion-moderate)]\",\n    // Without its own layer the label is re-rasterised on the main thread at\n    // every intermediate scale and snapped to the pixel grid each frame, so\n    // the text judders through the dip. A compositor layer scales the cached\n    // raster instead and re-rasters once it settles. Only `transform` — the\n    // one thing the press animates.\n    \"will-change-transform\",\n    // A real cursor, not `pointer-events-none`: an element with no pointer\n    // events can't show one. The hover effects below guard on `enabled`\n    // themselves, which is the work pointer-events used to do.\n    \"disabled:opacity-50 disabled:cursor-not-allowed\",\n    \"focus-visible:ring-1 focus-visible:ring-[color:var(--focus-ring,#6B97FF)]\",\n  ],\n  {\n    variants: {\n      variant: {\n        primary: \"text-background\",\n        secondary: \"text-foreground\",\n        tertiary: \"text-foreground\",\n        ghost: \"text-muted-foreground enabled:hover:text-foreground\",\n      },\n      size: {\n        sm: controlSize(\"h-[var(--control-h-sm,32px)]\", \"px-[var(--control-px-sm,12px)]\"),\n        default: controlSize(\"h-[var(--control-h,36px)]\", \"px-[var(--control-px,16px)]\"),\n        lg: controlSize(\"h-[var(--control-h-lg,40px)]\", \"px-[var(--control-px-lg,24px)]\"),\n        // The one step past shadcn's ladder. Its label leaves the shared 13px\n        // control step, as Tabs' lg does, and its glyph grows with the box.\n        xl: controlSize(\n          \"h-[var(--control-h-xl,48px)]\",\n          \"px-[var(--control-px-xl,32px)]\",\n          \"text-[length:var(--fs-subtitle,15px)]\"\n        ),\n        \"icon-sm\": iconOnlySize(\"size-[var(--control-icon-box-sm,32px)]\"),\n        icon: iconOnlySize(\"size-[var(--control-icon-box,36px)]\"),\n        \"icon-lg\": iconOnlySize(\"size-[var(--control-icon-box-lg,40px)]\"),\n        \"icon-xl\": iconOnlySize(\n          \"size-[var(--control-icon-box-xl,48px)]\",\n          \"[&_svg]:size-[var(--control-icon-glyph-xl,20px)]\"\n        ),\n      },\n      iconLeft: { true: \"\" },\n      iconRight: { true: \"\" },\n    },\n    compoundVariants: [\n      { iconLeft: true, className: \"pl-[var(--control-icon-pad,10px)]\" },\n      { iconRight: true, className: \"pr-[var(--control-icon-pad,10px)]\" },\n    ],\n    defaultVariants: {\n      variant: \"primary\",\n      size: \"default\",\n    },\n  }\n);\n\ntype ButtonSizeCanonical =\n  | \"sm\"\n  | \"default\"\n  | \"lg\"\n  | \"xl\"\n  | \"icon-sm\"\n  | \"icon\"\n  | \"icon-lg\"\n  | \"icon-xl\";\n\n/** Public size values: the canonical ladder plus `compact`/`icon-compact` for\n *  the dense 28px control, and `md`, kept so existing call sites compile. */\ntype ButtonSize = ButtonSizeCanonical | \"compact\" | \"icon-compact\" | \"md\";\n\n/** `compact` is `sm` rendered inside the compact scope — the ladder shifted\n *  down a step lands it back on 28px, the height it has always had. */\nconst sizeAliases: Partial<Record<ButtonSize, ButtonSizeCanonical>> = {\n  compact: \"sm\",\n  \"icon-compact\": \"icon-sm\",\n  md: \"default\",\n};\n\n/** The spinner tracks the button's own box, so it stays proportionate on every\n *  step rather than reading the default one's height at all three. */\nconst spinnerBox: Record<ButtonSizeCanonical, string> = {\n  sm: \"size-[var(--control-h-sm,32px)]\",\n  default: \"size-[var(--control-h,36px)]\",\n  lg: \"size-[var(--control-h-lg,40px)]\",\n  xl: \"size-[var(--control-h-xl,48px)]\",\n  \"icon-sm\": \"size-[var(--control-icon-box-sm,32px)]\",\n  icon: \"size-[var(--control-icon-box,36px)]\",\n  \"icon-lg\": \"size-[var(--control-icon-box-lg,40px)]\",\n  \"icon-xl\": \"size-[var(--control-icon-box-xl,48px)]\",\n};\n\ninterface ButtonProps\n  extends ButtonHTMLAttributes<HTMLButtonElement>,\n    Omit<VariantProps<typeof buttonVariants>, \"size\"> {\n  /** sm 32px, default 36px, lg 40px, xl 48px, and the `icon-*` squares to match.\n   *  Omitted, the button follows the ambient `data-size` scope, where compact\n   *  shifts the ladder down a step — see the `--control-*` tokens in\n   *  globals.css. `compact`/`icon-compact` are the dense 28px control. */\n  size?: ButtonSize;\n  /** When true, the given single React-element child becomes the rendered element (slot-style). */\n  asChild?: boolean;\n  loading?: boolean;\n  leadingIcon?: IconComponent;\n  trailingIcon?: IconComponent;\n  /** Force the visual pressed/held state. Useful when the button drives an\n   *  external open piece of UI (a popover, dropdown, etc.) so it reads as\n   *  engaged while the menu is showing. */\n  active?: boolean;\n}\n\n/* Press effect: the surface layer sits 1px inside the button and a\n   same-color box-shadow spread fills it back out to the full bounds.\n   Pressing collapses the spread, shrinking the surface by exactly 1px per\n   side at any width. The root's 0.96 press scale rides on top of this: the\n   1px collapse is the ground's own geometry and stays uniform at any width,\n   while the scale carries the label and icon along, which the collapse\n   alone never did. On a very wide button the scale reads more sideways\n   than down (2% of 400px is 8px against under 1px), which is the trade\n   the tactile press was chosen over. Fill colors are opaque color-mix()es\n   rather than alpha so the fill and its spread ring never seam.\n\n   This layer holds only the RESTING ground and that press geometry; every\n   hover and press *color* lives on the wash layer below, so the two can't\n   both paint a translucent fill and double its alpha. */\nconst bgVariants: Record<string, string> = {\n  primary:\n    \"[--btn-bg:var(--foreground)] bg-[var(--btn-bg)] shadow-[0_0_0_1px_var(--btn-bg)] group-active:shadow-[0_0_0_0px_var(--btn-bg)]\",\n  secondary:\n    \"[--btn-bg:var(--accent)] bg-[var(--btn-bg)] shadow-[0_0_0_1px_var(--btn-bg)] group-active:shadow-[0_0_0_0px_var(--btn-bg)]\",\n  // The border ring is an outer 1px shadow at rest that hands off to an\n  // inset 1px shadow when pressed, so the ring moves inward with the\n  // surface.\n  tertiary:\n    \"bg-transparent shadow-[0_0_0_1px_var(--border),inset_0_0_0_0px_var(--border)] group-active:shadow-[0_0_0_0px_var(--border),inset_0_0_0_1px_var(--border)]\",\n  ghost: \"bg-transparent\",\n};\n\n/* Hover ground. A second layer over the resting one, carrying every hover and\n   press fill, which BLOOMS out of the button's centre (75% → full) instead of\n   cross-fading in place.\n\n   It repeats the base layer's geometry — `inset-px` plus a same-color 1px\n   spread — so the ground reaches the button's real bounds and collapses with\n   it under a press, rather than leaving a hairline of the resting color\n   ringing a hovered face. Translucent fill + same-color spread never double\n   up: outer shadows render only outside the surface box.\n\n   The color is one custom property per variant rather than two sets of\n   classes, so the layer itself is written once. */\nconst washVariants: Record<string, string> = {\n  primary:\n    \"[--btn-wash:color-mix(in_oklab,var(--foreground)_90%,var(--background))] group-active:[--btn-wash:color-mix(in_oklab,var(--foreground)_80%,var(--background))]\",\n  secondary:\n    \"[--btn-wash:color-mix(in_oklab,var(--accent)_80%,var(--background))] group-active:[--btn-wash:var(--accent)]\",\n  tertiary: \"[--btn-wash:var(--hover)] group-active:[--btn-wash:var(--active)]\",\n  // Ghost has no resting ground, so the wash IS the button while hovered and\n  // a quicker exit reads as it blinking out. It leaves on the tier it arrived\n  // on instead; `cn` drops the quicker exit duration the layer sets by default.\n  ghost:\n    \"[--btn-wash:var(--hover)] group-active:[--btn-wash:var(--active)] duration-(--motion-moderate)\",\n};\n\n/* Forced-active (`active` prop): pressed colors at full size; the\n   geometric press-collapse still reacts on top. */\nconst activeBgVariants: Record<string, string> = {\n  primary:\n    \"[--btn-bg:color-mix(in_oklab,var(--foreground)_80%,var(--background))] bg-[var(--btn-bg)] shadow-[0_0_0_1px_var(--btn-bg)] group-active:shadow-[0_0_0_0px_var(--btn-bg)]\",\n  secondary:\n    \"[--btn-bg:var(--accent)] bg-[var(--btn-bg)] shadow-[0_0_0_1px_var(--btn-bg)] group-active:shadow-[0_0_0_0px_var(--btn-bg)]\",\n  tertiary:\n    \"bg-active shadow-[0_0_0_1px_var(--border),inset_0_0_0_0px_var(--border)] group-active:shadow-[0_0_0_0px_var(--border),inset_0_0_0_1px_var(--border)]\",\n  ghost:\n    \"bg-active shadow-[0_0_0_1px_var(--active)] group-active:shadow-[0_0_0_0px_var(--active)]\",\n};\n\nconst Button = forwardRef<HTMLButtonElement, ButtonProps>(\n  (\n    {\n      className,\n      variant,\n      size,\n      asChild = false,\n      loading = false,\n      leadingIcon: LeadingIcon,\n      trailingIcon: TrailingIcon,\n      active = false,\n      disabled,\n      children,\n      style,\n      ...props\n    },\n    ref\n  ) => {\n    // asChild: the user's element becomes the root while the button's internal\n    // structure (bg layer, content wrapper, spinner, icons) survives as its\n    // children — the element's own children become the label. We clone the\n    // element directly instead of routing through ButtonPrimitive's `render`:\n    // Base UI would bolt button semantics (role=\"button\", Space activation)\n    // onto e.g. a link, where plain-link output is wanted.\n    const asChildElement =\n      asChild && isValidElement(children)\n        ? (children as ReactElement<{\n            children?: ReactNode;\n            className?: string;\n            style?: React.CSSProperties;\n            ref?: React.Ref<HTMLButtonElement>;\n          }>)\n        : null;\n    const label = asChildElement ? asChildElement.props.children : children;\n    // Resolve the size from the explicit prop alone (legacy aliases mapped\n    // onto the canonical ladder) — no ambient SizeProvider read. Falling\n    // outside an explicit prop, \"default\" is just the JS fallback; the\n    // *rendered* size still follows an ambient `data-size=\"compact\"`\n    // ancestor via the `--control-*` vars in buttonVariants, CSS handles\n    // that resolution on its own.\n    const resolvedSize: ButtonSizeCanonical = size\n      ? sizeAliases[size] ?? (size as ButtonSizeCanonical)\n      : \"default\";\n    const isIconOnly = resolvedSize.startsWith(\"icon\");\n    const isCompact = size === \"compact\" || size === \"icon-compact\";\n    // Best-effort JS fallback for icon components that don't size off the\n    // `size-[var(--control-icon-glyph,16px)]` class below (e.g. a non-SVG glyph\n    // font) — accurate whenever `size` was passed explicitly, otherwise just\n    // the default-tier glyph size until CSS corrects it.\n    const iconSize = isCompact ? 14 : 16;\n    // A control label is an affordance, not prose: at the 13px control size\n    // regular reads too light, most visibly on `primary`, where light-on-dark\n    // optically thins. The weight is `font-medium` on the root rather than the\n    // `fontWeights.medium` variation (450): that pairing exists to hold a\n    // label's width while the weight ANIMATES, which a button's never does, and\n    // 500 is what Tabs' triggers carry — a control should not read lighter than\n    // the tab strip beside it.\n    const labelStyle: React.CSSProperties = { ...style };\n    const bgClass = active\n      ? activeBgVariants[variant ?? \"primary\"]\n      : bgVariants[variant ?? \"primary\"];\n\n    const internals = (\n      <>\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute inset-px rounded-[inherit] transition-[box-shadow,background-color] [transition-duration:180ms,80ms] [transition-timing-function:cubic-bezier(0.23,1,0.32,1),ease] group-active:[transition-duration:80ms,80ms]\",\n            bgClass\n          )}\n        />\n        {/* Forced-active paints the pressed color at full size on the layer\n            above, so a bloom on top of it would only re-tint an already-lit\n            button with the lighter hover shade. */}\n        {!active && (\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-px rounded-[inherit] scale-75 opacity-0 group-disabled:hidden\",\n              \"bg-[var(--btn-wash)] shadow-[0_0_0_1px_var(--btn-wash)]\",\n              \"transition-[scale,opacity,box-shadow,background-color] ease-spring\",\n              // Moderate, not the fast tier hover usually takes: a scale this\n              // small lands inside 80ms without ever reading as movement, and\n              // the bloom IS the effect. Leaving runs a tier quicker, so the\n              // ground recedes crisply instead of replaying the entrance.\n              \"duration-(--motion-moderate-exit) group-hover:duration-(--motion-moderate)\",\n              \"group-hover:scale-100 group-hover:opacity-100\",\n              // A touch screen has no hover, so the press has to raise the\n              // ground itself — without this the only feedback on a phone is\n              // the 1px collapse, which is far too quiet to register.\n              //\n              // Instantly, not on a tier. The enter duration rides\n              // `group-hover:`, which Tailwind wraps in @media (hover: hover)\n              // and a phone therefore never gets, so a press was blooming on\n              // the 120ms exit duration — and a tap holds :active for less\n              // than that, so the ground was still on its way up when the\n              // finger lifted and it reversed. Ghost feels it worst: with no\n              // resting ground the wash IS the entire press feedback. Landing\n              // it at once means the ground is simply there for as long as\n              // the finger is, and only the release animates — the same shape\n              // as the Tabs hover wash, which appears instantly and fades.\n              \"group-active:scale-100 group-active:opacity-100 group-active:duration-0\",\n              \"group-active:shadow-[0_0_0_0px_var(--btn-wash)]\",\n              washVariants[variant ?? \"primary\"]\n            )}\n          />\n        )}\n        <span className=\"relative inline-flex items-center justify-center gap-[inherit]\">\n          {loading ? (\n            <>\n              <span className=\"flex items-center justify-center gap-[inherit] opacity-0\">\n                {LeadingIcon && !isIconOnly && (\n                  <LeadingIcon size={iconSize} strokeWidth={2} className=\"size-[var(--control-icon-glyph,16px)]\" />\n                )}\n                {label}\n                {TrailingIcon && !isIconOnly && (\n                  <TrailingIcon size={iconSize} strokeWidth={2} className=\"size-[var(--control-icon-glyph,16px)]\" />\n                )}\n              </span>\n              <span className=\"absolute inset-0 flex items-center justify-center\">\n                {/* Tracks the button height so the loading glyph stays\n                    proportionate across sizes, ambient or explicit. */}\n                <svg\n                  className={spinnerBox[resolvedSize]}\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                >\n                  <path\n                    d=\"M 12 12 C 14 8.5 19 8.5 19 12 C 19 15.5 14 15.5 12 12 C 10 8.5 5 8.5 5 12 C 5 15.5 10 15.5 12 12 Z\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"1.125\"\n                    strokeLinecap=\"round\"\n                    pathLength=\"100\"\n                    style={{\n                      strokeDasharray: \"15 85\",\n                      animation: \"spinner-move 2s linear infinite, spinner-dash 4s ease-in-out infinite\",\n                    }}\n                  />\n                </svg>\n              </span>\n            </>\n          ) : isIconOnly ? (\n            <span\n              className={cn(\n                \"[&_svg]:stroke-[1.5] group-enabled:group-hover:[&_svg]:stroke-[2]\",\n                // The root's 0.96 is under a pixel on a 20px glyph, so on an\n                // icon square the press would go unseen. The glyph dips\n                // further, on the same tiers, and reads as pressing into the\n                // button.\n                \"[&_svg]:transition-[stroke-width,scale] [&_svg]:ease-spring\",\n                \"[&_svg]:[transition-duration:80ms,var(--motion-moderate-exit)]\",\n                \"group-active:[&_svg]:scale-90 group-active:[&_svg]:[transition-duration:80ms,var(--motion-moderate)]\"\n              )}\n            >\n              {label}\n            </span>\n          ) : (\n            <>\n              {LeadingIcon && (\n                <LeadingIcon\n                  size={iconSize}\n                  strokeWidth={1.5}\n                  className=\"size-[var(--control-icon-glyph,16px)] transition-[stroke-width] duration-80 group-enabled:group-hover:stroke-[2]\"\n                />\n              )}\n              {/* text-box only applies to block containers, so the trim lives\n                  on the label span (a blockified flex item), not the flex root.\n                  The button's height is fixed (h-*), so this doesn't change\n                  layout — it just centers the cap-to-baseline box optically. */}\n              <span className=\"[text-box:trim-both_cap_alphabetic]\">{label}</span>\n              {TrailingIcon && (\n                <TrailingIcon\n                  size={iconSize}\n                  strokeWidth={1.5}\n                  className=\"size-[var(--control-icon-glyph,16px)] transition-[stroke-width] duration-80 group-enabled:group-hover:stroke-[2]\"\n                />\n              )}\n            </>\n          )}\n        </span>\n      </>\n    );\n\n    const rootClassName = cn(\n      buttonVariants({\n        variant,\n        size: resolvedSize,\n        iconLeft: !isIconOnly && !!LeadingIcon,\n        iconRight: !isIconOnly && !!TrailingIcon,\n      }),\n      className\n    );\n    // An explicit size must win over whatever `--control-*` values an ambient\n    // data-size ancestor (or lack of one) would otherwise resolve to. Setting\n    // the attribute here — not just relying on the class above — makes the\n    // override apply on the element itself, where a plain attribute selector\n    // beats inheritance from any ancestor.\n    //\n    // Both directions, not just compact: subtree scoping is the supported way\n    // to size a region, so `size=\"default\"` inside a `data-size=\"compact\"`\n    // wrapper has to climb back out. Only an omitted prop stays ambient.\n    const dataSize = size ? (isCompact ? \"compact\" : \"default\") : undefined;\n\n    if (asChildElement) {\n      const childProps = asChildElement.props;\n      return cloneElement(\n        asChildElement,\n        {\n          ...props,\n          // Spread, not a literal key: cloneElement's inferred config type is\n          // narrower than ButtonHTMLAttributes (it only knows the target\n          // element's own props), so a literal \"data-size\" key fails\n          // TypeScript's excess-property check the way a spread doesn't.\n          ...(dataSize ? { \"data-size\": dataSize } : {}),\n          ref,\n          className: cn(rootClassName, childProps.className),\n          style: { ...labelStyle, ...childProps.style },\n        },\n        internals\n      );\n    }\n\n    return (\n      <ButtonPrimitive\n        // Base UI's `ButtonPrimitive` forwards to an HTMLButtonElement;\n        // keep the public ref type narrow so consumers see the right type.\n        ref={ref as React.Ref<HTMLButtonElement>}\n        className={rootClassName}\n        data-size={dataSize}\n        disabled={disabled || loading}\n        style={labelStyle}\n        {...props}\n      >\n        {internals}\n      </ButtonPrimitive>\n    );\n  }\n);\n\nButton.displayName = \"Button\";\n\nexport { Button, buttonVariants };\nexport type { ButtonProps, ButtonSize };\n",
      "type": "registry:ui",
      "target": "components/ui/button.tsx"
    }
  ],
  "css": {
    "@keyframes spinner-move": {
      "to": {
        "stroke-dashoffset": "-100"
      }
    },
    "@keyframes spinner-dash": {
      "0%, 100%": {
        "stroke-dasharray": "15 85"
      },
      "50%": {
        "stroke-dasharray": "40 60"
      }
    }
  },
  "docs": "Docs & live playground: https://www.deltacomponents.dev/docs/button.",
  "categories": [
    "components"
  ],
  "type": "registry:ui"
}
