{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"button","type":"registry:ui","title":"Button","description":"The foundational push-button: six variants, three sizes, a loading state, and a polymorphic href/anchor mode","categories":["actions"],"registryDependencies":["https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/button/Button.tsx","content":"\"use client\";\n\nimport {\n\tforwardRef,\n\ttype AnchorHTMLAttributes,\n\ttype ButtonHTMLAttributes,\n\ttype MouseEvent,\n\ttype Ref,\n\ttype ReactNode,\n} from \"react\";\nimport { cn } from \"../../utils\";\nimport { sound as soundFx } from \"../../sound/sound\";\nimport { useSoundFeedback } from \"../../sound/sound-feedback\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport type { ButtonVariant, ButtonSize } from \"./types\";\nimport \"./button.css\";\n\nexport interface ButtonProps extends Omit<\n\tButtonHTMLAttributes<HTMLButtonElement> & AnchorHTMLAttributes<HTMLAnchorElement>,\n\t\"onClick\" | \"className\" | \"type\"\n> {\n\t/** Visual treatment. */\n\tvariant?: ButtonVariant;\n\t/** Padding / font-size / radius scale. */\n\tsize?: ButtonSize;\n\t/** Native `type`. Ignored once `href` is set — an anchor has no `type`. */\n\ttype?: \"button\" | \"submit\" | \"reset\";\n\t/** Greys the button out and makes it inert to pointer and keyboard activation. */\n\tdisabled?: boolean;\n\t/**\n\t * Swaps `iconStart` for a spinner and marks the control `aria-busy`, without\n\t * dimming it the way `disabled` does — the button still reads as \"working\",\n\t * not \"unavailable\". Activation is blocked exactly like `disabled`.\n\t */\n\tloading?: boolean;\n\t/** Renders an `<a>` instead of a `<button>` when set. */\n\thref?: string;\n\t/** Anchor `target`. `\"_blank\"` forces a safe `rel` regardless of what `rel` says. */\n\ttarget?: string;\n\t/** Anchor `rel`. Widened, never narrowed — see `target`. */\n\trel?: string;\n\t/** Stretches the button to fill its container's width. */\n\tfullWidth?: boolean;\n\t/** Accessible name for a button whose content is icon-only. */\n\tlabel?: string;\n\t/** Fires on activation. Never called while `disabled` or `loading`. */\n\tonClick?: (event: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => void;\n\t/** Rendered before the label. Replaced by the spinner while `loading`. */\n\ticonStart?: ReactNode;\n\t/** Rendered after the label. */\n\ticonEnd?: ReactNode;\n\t/** The button's label / content. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes. */\n\tclassName?: string;\n\t/**\n\t * Plays the `hover` cue on mouse/pen pointer enter and the `press` cue on\n\t * click. Off by default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nconst SIZE_CLASSES: Record<ButtonSize, string> = {\n\tsm: \"rounded-[6px] px-[12px] py-[5px] text-[12px]\",\n\tmd: \"rounded-[8px] px-[18px] py-[9px] text-[13px]\",\n\tlg: \"rounded-[10px] px-[24px] py-[12px] text-[14px]\",\n};\n\nconst VARIANT_CLASSES: Record<ButtonVariant, string> = {\n\tprimary: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n\tsecondary: \"bg-secondary text-secondary-foreground hover:bg-secondary/80\",\n\toutline: \"border border-border text-foreground hover:bg-accent hover:text-accent-foreground\",\n\tghost: \"text-foreground hover:bg-accent hover:text-accent-foreground\",\n\t// Colour lives in the colocated CSS: the brand purple has no semantic\n\t// Tailwind token, so it is a family-level CSS custom property instead.\n\taccent: \"ft-btn--accent\",\n\tdestructive:\n\t\t\"border border-destructive/35 bg-destructive/10 text-destructive hover:bg-destructive/20\",\n};\n\nexport const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(\n\t(\n\t\t{\n\t\t\tvariant = \"primary\",\n\t\t\tsize = \"md\",\n\t\t\ttype = \"button\",\n\t\t\tdisabled = false,\n\t\t\tloading = false,\n\t\t\thref = undefined,\n\t\t\ttarget = undefined,\n\t\t\trel = undefined,\n\t\t\tfullWidth = false,\n\t\t\tlabel = undefined,\n\t\t\tonClick,\n\t\t\ticonStart,\n\t\t\ticonEnd,\n\t\t\tchildren,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t\t...restProps\n\t\t},\n\t\tref\n\t) => {\n\t\tconst classes = cn(\n\t\t\t\"ft-btn\",\n\t\t\t\"inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium\",\n\t\t\t// No `transition-colors` here: the colour channel is declared in\n\t\t\t// `button.css` instead, alongside the press, so one rule owns the\n\t\t\t// whole transition list. The utility would only add a second,\n\t\t\t// competing declaration over the same three properties.\n\t\t\t\"cursor-pointer\",\n\t\t\t\"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-[var(--ft-btn-accent)]/35\",\n\t\t\t// `data-disabled` covers the anchor branch, which has no native `:disabled`\n\t\t\t// pseudo-class to hang the same dimmed treatment off. It tracks `disabled`\n\t\t\t// alone, never `loading` — `aria-disabled` also goes true while loading (see\n\t\t\t// `anchorBlocked` below), but the mockup's loading swatch is explicitly not\n\t\t\t// dimmed, so the visual hook and the a11y attribute must stay two different\n\t\t\t// things even though they overlap when `disabled` is set.\n\t\t\t\"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50\",\n\t\t\t\"data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50\",\n\t\t\tSIZE_CLASSES[size],\n\t\t\tVARIANT_CLASSES[variant],\n\t\t\tfullWidth && \"w-full\",\n\t\t\tclassName\n\t\t);\n\n\t\t// `target=\"_blank\"` without `noopener` lets the opened page reach back into\n\t\t// this one via `window.opener`; a caller-supplied `rel` is widened rather\n\t\t// than trusted, so the safe tokens are always present even if they forgot.\n\t\tconst resolvedRel = (() => {\n\t\t\tif (target !== \"_blank\") return rel;\n\t\t\tconst tokens = new Set((rel ?? \"\").split(/\\s+/).filter(Boolean));\n\t\t\ttokens.add(\"noopener\");\n\t\t\ttokens.add(\"noreferrer\");\n\t\t\treturn [...tokens].join(\" \");\n\t\t})();\n\n\t\t// An <a> has no native `disabled` state. `disabled` strips `href`/`target`\n\t\t// and leaves the tab order (nothing to activate, nothing to read). `loading`\n\t\t// is different: the control is busy, not unavailable, so it keeps its `href`\n\t\t// and its tab stop — focus must not vanish under a keyboard user mid-request —\n\t\t// and activation is blocked in the handlers instead (`click` for keyboard and\n\t\t// primary pointer, `auxclick` for middle-click). `aria-busy` + `aria-disabled`\n\t\t// tell assistive tech why nothing happens.\n\t\tconst anchorInert = disabled;\n\t\tconst anchorBlocked = disabled || loading;\n\n\t\t// Hover cue for mouse/pen pointers only. `on: {}` binds nothing when sound is\n\t\t// off (and never the default click → press, which `handleClick` already plays).\n\t\tconst soundRef = useSoundFeedback({\n\t\t\ton: sound ? { pointerenter: \"hover\" } : {},\n\t\t\tdisabled: anchorBlocked,\n\t\t});\n\t\tconst composedRef = useComposedRefs(ref as Ref<HTMLElement>, soundRef);\n\n\t\t// The single guard both branches funnel through. A native `disabled` button\n\t\t// already refuses real pointer/keyboard input, but a synthetic `.click()` (or\n\t\t// an anchor, which has no disabled state at all) walks straight past that —\n\t\t// this is what actually keeps the callback from firing.\n\t\t// Middle-click guard for the anchor branch: internal first, then the consumer's\n\t\t// own `onAuxClick` (never overwritten), skipped while blocked like `onClick`.\n\t\tfunction handleAuxClick(event: MouseEvent<HTMLAnchorElement>) {\n\t\t\tif (disabled || loading) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t(restProps as AnchorHTMLAttributes<HTMLAnchorElement>).onAuxClick?.(event);\n\t\t}\n\n\t\tfunction handleClick(event: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) {\n\t\t\tif (disabled || loading) {\n\t\t\t\tevent.preventDefault();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (sound) soundFx.play(\"press\");\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\tconst content = (\n\t\t\t<>\n\t\t\t\t{/* The lead slot: one fixed cell for the spinner or iconStart, so a\n\t\t\t\t    loading swap never moves the label and an icon centres exactly as\n\t\t\t\t    in the Svelte and Vue buttons. aria-hidden only while loading:\n\t\t\t\t    the caller's own icon keeps whatever treatment they gave it. */}\n\t\t\t\t{loading || (iconStart != null && iconStart !== false) ? (\n\t\t\t\t\t<span className=\"ft-btn-lead\" aria-hidden={loading ? \"true\" : undefined}>\n\t\t\t\t\t\t{loading ? (\n\t\t\t\t\t\t\t<span className=\"ft-btn-spinner\"></span>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<span className=\"ft-btn-lead-icon\">{iconStart}</span>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</span>\n\t\t\t\t) : null}\n\t\t\t\t{children}\n\t\t\t\t{iconEnd}\n\t\t\t</>\n\t\t);\n\n\t\tif (href) {\n\t\t\treturn (\n\t\t\t\t<a\n\t\t\t\t\t{...(restProps as AnchorHTMLAttributes<HTMLAnchorElement>)}\n\t\t\t\t\tref={composedRef as Ref<HTMLAnchorElement>}\n\t\t\t\t\tclassName={classes}\n\t\t\t\t\thref={anchorInert ? undefined : href}\n\t\t\t\t\ttarget={anchorInert ? undefined : target}\n\t\t\t\t\trel={resolvedRel}\n\t\t\t\t\taria-label={label ?? restProps[\"aria-label\"]}\n\t\t\t\t\tdata-disabled={\n\t\t\t\t\t\tdisabled\n\t\t\t\t\t\t\t? \"true\"\n\t\t\t\t\t\t\t: ((restProps as Record<string, unknown>)[\"data-disabled\"] as string | undefined)\n\t\t\t\t\t}\n\t\t\t\t\taria-disabled={anchorBlocked ? \"true\" : restProps[\"aria-disabled\"]}\n\t\t\t\t\taria-busy={loading ? \"true\" : restProps[\"aria-busy\"]}\n\t\t\t\t\ttabIndex={anchorInert ? -1 : restProps.tabIndex}\n\t\t\t\t\tonClick={handleClick}\n\t\t\t\t\tonAuxClick={handleAuxClick}\n\t\t\t\t>\n\t\t\t\t\t{content}\n\t\t\t\t</a>\n\t\t\t);\n\t\t}\n\n\t\treturn (\n\t\t\t<button\n\t\t\t\t{...(restProps as ButtonHTMLAttributes<HTMLButtonElement>)}\n\t\t\t\tref={composedRef as Ref<HTMLButtonElement>}\n\t\t\t\tclassName={classes}\n\t\t\t\ttype={type}\n\t\t\t\tdisabled={disabled}\n\t\t\t\taria-label={label ?? restProps[\"aria-label\"]}\n\t\t\t\taria-busy={loading ? \"true\" : restProps[\"aria-busy\"]}\n\t\t\t\tonClick={handleClick}\n\t\t\t>\n\t\t\t\t{content}\n\t\t\t</button>\n\t\t);\n\t}\n);\n\nButton.displayName = \"Button\";\n","type":"registry:ui","target":"@ui/fancy/components/button/Button.tsx"},{"path":"react/src/components/button/button.css","content":".ft-btn {\n\t--ft-btn-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n\t/* 150ms = tokens.DURATIONS.fast, cubic-bezier(0.4, 0, 0.2, 1) =\n\t   tokens.EASINGS.inout — the reversible-state pair. It runs the colour\n\t   channel, and the release half of the press: letting go settles, it does\n\t   not snap back. */\n\t--ft-btn-motion: var(--ft-duration-fast, 150ms) var(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1));\n\t/* The down-stroke only. 150ms = tokens.DURATIONS.fast,\n\t   cubic-bezier(0.16, 1, 0.3, 1) = tokens.EASINGS.out — the arrival curve,\n\t   which spends most of the scale in the first frames, so the press bites\n\t   instead of easing in. Pressing is an arrival; releasing is what resolves\n\t   either way. */\n\t--ft-btn-press: var(--ft-duration-fast, 150ms) var(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t/*\n\t * Replaces the `transition-colors` utility removed from the class string,\n\t * at exactly the values that utility already resolved to. Colour is a state\n\t * change, not motion, so it stays OUTSIDE the reduced-motion query — gating\n\t * it would only make a theme flip look broken for the people who asked for\n\t * less movement.\n\t *\n\t * `box-shadow` is deliberately absent, and must stay absent: this button's\n\t * focus ring is `focus-visible:ring-*`, which compiles to a `box-shadow`,\n\t * and a focus ring must never animate.\n\t */\n\ttransition:\n\t\tcolor var(--ft-btn-motion),\n\t\tbackground-color var(--ft-btn-motion),\n\t\tborder-color var(--ft-btn-motion);\n\t/* Kills the ~300ms tap delay without blocking scroll. A press that answers\n\t   a third of a second late is not press feedback. */\n\ttouch-action: manipulation;\n}\n\n/*\n * The press. Pressable's contract inlined as a plain rule on the native\n * control rather than wrapping a `<button>` in a `<Pressable>` div — same\n * `0.97`, same clock, one element instead of two.\n *\n * Only the property list is re-declared in here: `transform` joins the colour\n * channel under `no-preference` and nowhere else, so with motion reduced the\n * colours still cross and the button simply does not move. The resting state\n * (no `transform` at all) is the ungated fallback.\n *\n * The two strokes are timed apart. The rule below carries the release curve,\n * because that is the transition the button runs on its way back to rest;\n * `:active` overrides `transform` with the arrival curve for the way down.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-btn {\n\t\ttransition:\n\t\t\tcolor var(--ft-btn-motion),\n\t\t\tbackground-color var(--ft-btn-motion),\n\t\t\tborder-color var(--ft-btn-motion),\n\t\t\ttransform var(--ft-btn-motion);\n\t}\n\n\t/* Both inert states are excluded so a press that does nothing does not\n\t   pretend to. `[data-disabled]` is the anchor branch, which has no native\n\t   `:disabled` to hang this off; `[aria-busy]` is the loading one, on both\n\t   branches. A native disabled `<button>` never matches `:active` anyway —\n\t   the attribute selectors are what make the anchor behave like it. */\n\t.ft-btn:not([data-disabled=\"true\"]):not([aria-busy=\"true\"]):active {\n\t\ttransform: scale(0.97);\n\t\t/* The whole list is re-declared rather than just\n\t\t   `transition-timing-function`, which has no per-property form here: a\n\t\t   single value would retime the colour channel too. Only `transform`\n\t\t   differs from the rule above. */\n\t\ttransition:\n\t\t\tcolor var(--ft-btn-motion),\n\t\t\tbackground-color var(--ft-btn-motion),\n\t\t\tborder-color var(--ft-btn-motion),\n\t\t\ttransform var(--ft-btn-press);\n\t}\n}\n\n.ft-btn--accent {\n\tbackground: var(--ft-btn-accent);\n\tcolor: var(--ft-accent-foreground, oklch(1 0 0));\n}\n\n.ft-btn--accent:hover {\n\tbackground: color-mix(in oklab, var(--ft-btn-accent) 90%, transparent);\n}\n\n/*\n * The lead slot: one fixed cell the spinner and `iconStart` share, sized\n * exactly like the spinner it holds (`calc(1em + 1px)`, read from the\n * button's own font-size, so it follows the size variant for free).\n */\n.ft-btn-lead {\n\tdisplay: grid;\n\tplace-items: center;\n\tflex: none;\n\twidth: calc(1em + 1px);\n\theight: calc(1em + 1px);\n}\n\n.ft-btn-lead > .ft-btn-spinner,\n.ft-btn-lead > .ft-btn-lead-icon {\n\tgrid-area: 1 / 1;\n}\n\n.ft-btn-lead-icon {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n}\n\n/*\n * `1em` reads the button's own font-size, so the ring scales with the size\n * variant instead of needing one fixed diameter per size — at `md` (13px\n * type) that resolves to the mockup's 14px exactly.\n */\n.ft-btn-spinner {\n\tflex: none;\n\twidth: calc(1em + 1px);\n\theight: calc(1em + 1px);\n\tborder-radius: 50%;\n\tborder: 2px solid color-mix(in oklab, currentColor 30%, transparent);\n\tborder-top-color: currentColor;\n}\n\n/*\n * Reduced motion leaves the ring exactly where a static ring already reads as\n * \"busy\" — `aria-busy` carries the meaning for assistive tech either way, so\n * there is nothing to swap out here, only the spin itself to drop.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-btn-spinner {\n\t\tanimation: ft-btn-spin 0.8s linear infinite;\n\t}\n}\n\n@keyframes ft-btn-spin {\n\tto {\n\t\ttransform: rotate(360deg);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/button/button.css"},{"path":"react/src/components/button/index.ts","content":"\"use client\";\n\nexport { Button, type ButtonProps } from \"./Button\";\nexport type { ButtonVariant, ButtonSize } from \"./types\";\n","type":"registry:ui","target":"@ui/fancy/components/button/index.ts"},{"path":"react/src/components/button/types.ts","content":"\"use client\";\n\n/** Visual treatment of a {@link import(\"./Button\").Button | Button}. */\nexport type ButtonVariant = \"primary\" | \"secondary\" | \"outline\" | \"ghost\" | \"accent\" | \"destructive\";\n\n/** Padding / font-size / radius scale of a Button. */\nexport type ButtonSize = \"sm\" | \"md\" | \"lg\";\n","type":"registry:ui","target":"@ui/fancy/components/button/types.ts"}]}