{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "quack-button", "title": "Quack Button", "description": "Button with a full motion cycle: an idle animation while it waits, magnetic pull toward the pointer, a squash on press, and animated loading, success and error states. Sizes run from xs and icon-xs — the 24–28px instrument scale a control rail is built from, radius and icon size included — up to lg. The loading mark is swappable, so a theme with no mascot can drop in a plain spinner.", "dependencies": [ "class-variance-authority", "@radix-ui/react-slot", "lucide-react" ], "registryDependencies": [ "@duck/theme", "@duck/duck-spinner", "@duck/use-holo-pointer" ], "files": [ { "path": "registry/duck/ui/quack-button.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Slot } from \"@radix-ui/react-slot\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { Check, TriangleAlert } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { DuckGlyph } from \"@/components/ui/duck-spinner\";\nimport { useHoloPointer } from \"@/hooks/use-holo-pointer\";\n\n/**\n * QuackButton — a button with a full motion cycle: an idle animation while\n * it waits, magnetic pull as the pointer approaches, a squash on press, and\n * an animated loading / success / error transition.\n *\n * Every transform is composed from CSS variables so hover lift, magnetism\n * and press never fight each other.\n *\n * Label typography comes from the --*-button tokens, not from these classes —\n * see the note on HoloButton.\n */\n\nconst quackButtonVariants = cva(\n [\n \"group/quack relative inline-flex items-center justify-center gap-2 overflow-hidden\",\n \"whitespace-nowrap cursor-pointer select-none\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:pointer-events-none disabled:opacity-50\",\n \"[&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4\",\n \"transform-gpu transition-[transform,background-color,box-shadow,color] duration-300 ease-[var(--ease-duck)]\",\n \"translate-x-[var(--mx,0px)] translate-y-[calc(var(--my,0px)+var(--lift,0px))] scale-[var(--press,1)]\",\n \"hover:[--lift:-2px] active:[--press:0.96] active:duration-75\",\n ],\n {\n variants: {\n variant: {\n primary:\n \"bg-primary text-primary-foreground hover:bg-primary/92 hover:duck-glow-primary\",\n holo: \"holo-border-animated text-foreground hover:duck-glow\",\n outline:\n \"sticker border-border bg-transparent text-foreground hover:border-primary hover:text-primary\",\n ghost: \"text-foreground hover:bg-secondary\",\n danger:\n \"bg-destructive text-destructive-foreground hover:bg-destructive/90\",\n },\n /**\n * Three scales of instrument, then three of button. A control rail runs on\n * 24–28px icon buttons — a layer row 32px tall carrying five of them, a\n * reset beside a slider readout — and the smallest button here used to be\n * h-8, so every one of those call sites wrote `size=\"icon\"` plus\n * `size-6 rounded-md` and had to get the radius pair right by hand.\n *\n * The radius steps down with the box: `rounded-lg` on a 24px square reads\n * as a circle with corners. For an exact 24px control take `icon-xs` and\n * add `size-6` — the radius and the icon scale still come from here.\n */\n size: {\n xs: \"h-7 gap-1.5 rounded-md px-2 [&_svg:not([class*='size-'])]:size-3.5\",\n sm: \"h-8 rounded-md px-3\",\n default: \"h-10 rounded-lg px-5\",\n lg: \"h-12 rounded-xl px-7\",\n \"icon-xs\": \"size-7 rounded-md [&_svg:not([class*='size-'])]:size-3.5\",\n \"icon-sm\": \"size-8 rounded-md [&_svg:not([class*='size-'])]:size-3.5\",\n icon: \"size-10 rounded-lg\",\n },\n },\n defaultVariants: { variant: \"primary\", size: \"default\" },\n }\n);\n\nconst idleAnimations = {\n none: \"\",\n /** Breathes once every five seconds. For a resting primary CTA. */\n breathe: \"[animation:duck-idle_5s_ease-in-out_infinite]\",\n /** Light passes over the surface. For anything with a filled background. */\n sheen:\n \"after:absolute after:inset-0 after:bg-[linear-gradient(105deg,transparent_38%,oklch(1_0_0/0.3)_48%,transparent_58%)] after:bg-[length:250%_100%] after:[animation:duck-sheen_5s_ease-in-out_infinite]\",\n /** A ring pushes outward on a loop. For a single, unmissable action. */\n pulse: \"\",\n /** Bobs like something floating. For playful, low-stakes actions. */\n float: \"[animation:duck-float_4s_ease-in-out_infinite]\",\n} as const;\n\nexport type QuackButtonState = \"idle\" | \"loading\" | \"success\" | \"error\";\n\nexport interface QuackButtonProps\n extends Omit, \"children\">,\n VariantProps {\n asChild?: boolean;\n children?: React.ReactNode;\n /** Animation played while the button rests. */\n idle?: keyof typeof idleAnimations;\n /** Pull toward the pointer, in px. 0 turns magnetism off. */\n magnetic?: number;\n /** Water ripple from the exact press point. */\n ripple?: boolean;\n /** Drives the loading / success / error transition. */\n state?: QuackButtonState;\n /** Mark shown while loading. Any image URL; defaults to the duck/ui logo. */\n markSrc?: string;\n /**\n * Anything to render in place of the paddling mark while loading — a lucide\n * spinner, a themed glyph, nothing at all. A theme with no mascot had\n * nowhere to put one of these, and the default mark 404s in a project that\n * never installed duck-spinner's asset. EmptyPond's `art` prop is the same\n * escape hatch.\n */\n loadingIndicator?: React.ReactNode;\n loadingLabel?: string;\n successLabel?: string;\n errorLabel?: string;\n}\n\nfunction QuackButton({\n className,\n variant = \"primary\",\n size = \"default\",\n asChild = false,\n children,\n idle = \"none\",\n magnetic = 0,\n ripple = true,\n state = \"idle\",\n markSrc,\n loadingIndicator,\n loadingLabel,\n successLabel,\n errorLabel,\n disabled,\n onPointerDown,\n ...props\n}: QuackButtonProps) {\n const ref = useHoloPointer({\n tilt: 0,\n magnet: magnetic,\n disabled: !magnetic,\n });\n\n const handlePointerDown = React.useCallback(\n (event: React.PointerEvent) => {\n onPointerDown?.(event);\n if (!ripple || event.defaultPrevented) return;\n const host = event.currentTarget;\n const rect = host.getBoundingClientRect();\n const drop = document.createElement(\"span\");\n const diameter = Math.max(rect.width, rect.height);\n drop.className =\n \"pointer-events-none absolute rounded-full bg-current [animation:duck-ripple_0.6s_ease-out_forwards]\";\n drop.style.width = drop.style.height = `${diameter}px`;\n drop.style.left = `${event.clientX - rect.left - diameter / 2}px`;\n drop.style.top = `${event.clientY - rect.top - diameter / 2}px`;\n drop.addEventListener(\"animationend\", () => drop.remove(), { once: true });\n host.appendChild(drop);\n },\n [onPointerDown, ripple]\n );\n\n const busy = state === \"loading\";\n const label =\n (busy && loadingLabel) ||\n (state === \"success\" && successLabel) ||\n (state === \"error\" && errorLabel) ||\n children;\n\n const Comp = asChild ? Slot : \"button\";\n\n const body = (\n <>\n {state !== \"idle\" && (\n \n {busy &&\n (loadingIndicator !== undefined ? (\n loadingIndicator\n ) : (\n \n ))}\n {state === \"success\" && }\n {state === \"error\" && }\n \n )}\n {label}\n \n );\n\n return (\n \n {/* Slot counts children with React.Children.count, which does not filter\n falsy ones — a second child throws even when it renders nothing. And\n the pulse ring decorates the button's own box, so it has no meaning\n once Slot is cloning someone else's element. */}\n {asChild ? (\n children\n ) : (\n <>\n {body}\n {idle === \"pulse\" && state === \"idle\" && (\n \n )}\n \n )}\n \n );\n}\n\nexport { QuackButton, quackButtonVariants };\n", "type": "registry:ui" } ], "type": "registry:ui" }