{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"motion-button","type":"registry:component","title":"Button (Motion)","description":"Button with motion: it presses in on a spring, a new label crosses over while the button glides to its new width, and loading pops the spinner in.","dependencies":["lucide-react","motion"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/motion-odometer.json","https://dashral.com/r/motion-reduced.json","https://dashral.com/r/motion-text-morph.json","https://dashral.com/r/motion-tokens.json","https://dashral.com/r/button.json"],"files":[{"path":"registry/dashral/motion/button.tsx","type":"registry:file","target":"components/dashral/motion/button.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { Loader2 } from \"lucide-react\"\nimport { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent, useTransform, type Variants } from \"motion/react\"\nimport { buttonVariants } from \"../button\"\nimport { useLocale } from \"../lib/locale\"\nimport { cn } from \"../lib/utils\"\n\nimport { Odometer } from \"../lib/motion-odometer\"\nimport { TextMorph } from \"../lib/motion-text-morph\"\nimport { useReducedMotion } from \"../lib/motion-reduced\"\nimport { duration, ease, spring } from \"../lib/motion-tokens\"\n\ntype StyleProps = Omit<NonNullable<Parameters<typeof buttonVariants>[0]>, \"className\">\n\n// motion/react gives these its own meaning (drag and animation events), so a plain button's versions are left out.\ntype NativeProps = Omit<React.ComponentProps<\"button\">, \"color\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\">\n\nexport type ButtonProps = NativeProps &\n  StyleProps & {\n    /** Shows a spinner in place of the label and ignores clicks, without moving focus. */\n    loading?: boolean\n    /** Beside the spinner while loading, e.g. \"Saving…\"; without it the spinner shows alone. */\n    loadingText?: React.ReactNode\n    /**\n     * For an action that can't be undone: a click isn't enough, it has to be pressed and held. A fill sweeps across\n     * while it's held and runs onClick (and submits its form) once full; let go early and it drains back. true holds\n     * for 1.2s, a number for that many milliseconds. Space and Enter hold it from the keyboard.\n     */\n    holdToConfirm?: boolean | number\n    /**\n     * How the hold shows its progress: \"fill\" sweeps across the button; \"radial\" grows a circle from where it's\n     * pressed; \"ring\" traces the button's edge; \"bar\" fills a thin line along its foot; \"countdown\" turns the label\n     * into the seconds left, on odometer wheels, over a bar.\n     */\n    holdStyle?: \"fill\" | \"radial\" | \"ring\" | \"bar\" | \"countdown\"\n    /**\n     * A label that's only text morphs into the next one letter by letter (Follow to Following keeps \"Follow\" and grows\n     * \"ing\") instead of crossing over. Off by default.\n     */\n    morph?: boolean\n  }\n\n/** A light buzz on phones that have one. */\nconst buzz = (pattern: number | number[]) => {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) navigator.vibrate(pattern)\n}\n\n/**\n * What the label is, for telling a new label from a re-render of the same one: its text and the names of the elements\n * in it. A new label (or icon) crosses over; the same one with new props stays put.\n */\nfunction labelKey(node: React.ReactNode): string {\n  if (node === null || node === undefined || typeof node === \"boolean\") return \"\"\n  if (typeof node === \"string\" || typeof node === \"number\" || typeof node === \"bigint\") return String(node)\n  if (Array.isArray(node)) return node.map(labelKey).join(\"\")\n  if (!React.isValidElement(node)) return \"\"\n  const type = node.type as string | { displayName?: string; name?: string }\n  const name = typeof type === \"string\" ? type : (type.displayName ?? type.name ?? \"\")\n  return `<${name}>${labelKey((node.props as { children?: React.ReactNode }).children)}`\n}\n\n/** How far it presses in: an icon button a little deeper, a wide one a little less, so each feels like the same push. */\nconst press: Variants = {\n  pressed: (button: React.RefObject<HTMLButtonElement | null>) => {\n    const width = button.current?.offsetWidth ?? 0\n    return { scale: width > 200 ? 0.985 : width > 0 && width <= 44 ? 0.95 : 0.97, transition: spring.press }\n  },\n}\n\n/** A layer of the label's grid cell, laid out like the button itself (its gap included). */\nconst layer = \"col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]\"\n\n/**\n * The kit's Button with motion: the same variants, colors, sizes and props, and it adds what CSS can't do on its own.\n * It presses in on a spring, as deep as its size calls for; a new label crosses over (rising as it fades in), or with\n * morph a text label changes letter by letter (Follow to Following keeps Follow and grows \"ing\"), while the button\n * glides to its new width instead of snapping; and loading pops the spinner (and loadingText) in over the label,\n * keeping the width. Disabled, loading, reduced motion, or opening a menu or popover (Radix places the layer from the\n * trigger as it opens), it doesn't press in. With holdToConfirm, it asks to be held: a fill sweeps across, and when\n * it's full the button pops and the action runs (phones buzz); let go early and the fill drains back. No asChild: for\n * a link that looks like a button, use the kit's Button.\n */\nexport function Button({\n  className,\n  variant,\n  color,\n  size,\n  glow,\n  loading = false,\n  loadingText,\n  holdToConfirm,\n  holdStyle = \"fill\",\n  morph = false,\n  children,\n  onClick,\n  onPointerDown,\n  onPointerUp,\n  onPointerLeave,\n  onPointerCancel,\n  onKeyDown,\n  onKeyUp,\n  ref,\n  ...props\n}: ButtonProps) {\n  const reduced = useReducedMotion()\n  const { t, locale } = useLocale()\n  const button = React.useRef<HTMLButtonElement | null>(null)\n  // Its own ref (the press measures the button) and the one it's given (a Radix trigger's, a form library's), both.\n  const setRef = React.useCallback(\n    (node: HTMLButtonElement | null) => {\n      button.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref]\n  )\n  const content = React.useRef<HTMLSpanElement | null>(null)\n  const width = useMotionValue<number | \"auto\">(\"auto\")\n  const key = labelKey(children)\n  const iconOnly = !/[^\\s]/.test(key.replace(/<[^>]*>/g, \"\"))\n\n  // The width glides only when the label itself changes; anything else that resizes it (a web font arriving, a parent's\n  // layout) is followed at once, so nothing wobbles on first paint.\n  const gliding = React.useRef(false)\n  const lastKey = React.useRef(key)\n  React.useLayoutEffect(() => {\n    if (lastKey.current === key) return\n    lastKey.current = key\n    gliding.current = true\n  }, [key])\n  React.useEffect(() => {\n    const node = content.current\n    if (!node) return\n    const observer = new ResizeObserver(([entry]) => {\n      const next = entry?.borderBoxSize[0]?.inlineSize ?? node.offsetWidth\n      if (!next) return\n      if (!gliding.current || reduced || width.get() === \"auto\") {\n        width.jump(next)\n        gliding.current = false\n        return\n      }\n      animate(width, next, { ...spring.glide, onComplete: () => (gliding.current = false) })\n    })\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [reduced, width])\n\n  const popup = props[\"aria-haspopup\"]\n  const anchorsLayer = (popup !== undefined && popup !== false && popup !== \"false\") || props.role === \"combobox\" || \"data-state\" in props\n  const still = reduced || props.disabled || loading || anchorsLayer\n\n  // Hold to confirm: how full the fill is, and whether the click now coming is the one the hold earned.\n  const holdMs = holdToConfirm === true ? 1200 : typeof holdToConfirm === \"number\" ? holdToConfirm : 0\n  const fill = useMotionValue(0)\n  const filling = React.useRef<ReturnType<typeof animate> | null>(null)\n  const earned = React.useRef(false)\n  const holding = React.useRef(false)\n  // A hold that ran ends with the pointer being let go, and the browser sends a click for that too: by then the button\n  // may already be something else (Restore, Undo), so that one click is swallowed. The next press clears it.\n  const spent = React.useRef(false)\n  // The countdown's seconds left, in tenths, while a hold runs.\n  const [counting, setCounting] = React.useState(false)\n  const [left, setLeft] = React.useState(holdMs / 1000)\n  useMotionValueEvent(fill, \"change\", (v) => {\n    if (holdStyle !== \"countdown\") return\n    const next = Math.max(0, Math.ceil((1 - v) * holdMs / 100) / 10)\n    setLeft((was) => (was === next ? was : next))\n  })\n  const seconds = React.useMemo(\n    () => new Intl.NumberFormat(locale, { style: \"unit\", unit: \"second\", unitDisplay: \"narrow\", minimumFractionDigits: 1, maximumFractionDigits: 1 }),\n    [locale]\n  )\n  const radial = React.useRef<HTMLSpanElement>(null)\n  // An empty ring hides: a path drawn to nothing still shows its round cap as a dot.\n  const ringShown = useTransform(fill, (v) => (v > 0.005 ? 1 : 0))\n  const ring = React.useRef<SVGRectElement>(null)\n  const ringTrack = React.useRef<SVGRectElement>(null)\n  /** Sizes what the style draws to the button as it is now: the circle around the press, the edge's path. */\n  const prepare = (point: { x: number; y: number } | null) => {\n    const node = button.current\n    if (!node) return\n    // Inside the border: the layers sit in the padding box, which is also where the button clips them.\n    const w = node.clientWidth\n    const h = node.clientHeight\n    if (radial.current) {\n      const x = point?.x ?? w / 2\n      const y = point?.y ?? h / 2\n      // Big enough to cover the farthest corner from where it's pressed.\n      const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y))\n      Object.assign(radial.current.style, { left: `${x - r}px`, top: `${y - r}px`, width: `${r * 2}px`, height: `${r * 2}px` })\n    }\n    if (ring.current) {\n      const css = getComputedStyle(node)\n      // The corner inside the border, then half the stroke in from there, so the line runs evenly all the way round.\n      const inner = Math.max(0, (parseFloat(css.borderTopLeftRadius) || 0) - (parseFloat(css.borderTopWidth) || 0))\n      const rx = String(Math.max(0, Math.min(inner - 1.5, h / 2 - 1.5)))\n      for (const rect of [ring.current, ringTrack.current]) {\n        if (!rect) continue\n        rect.setAttribute(\"width\", String(Math.max(0, w - 3)))\n        rect.setAttribute(\"height\", String(Math.max(0, h - 3)))\n        rect.setAttribute(\"rx\", rx)\n      }\n    }\n  }\n  const startHold = (touch: boolean, point: { x: number; y: number } | null = null) => {\n    if (!holdMs || props.disabled || loading || holding.current) return\n    holding.current = true\n    prepare(point)\n    if (holdStyle === \"countdown\") {\n      setLeft(holdMs / 1000)\n      setCounting(true)\n    }\n    if (touch) buzz(4)\n    filling.current?.stop()\n    // A fill left full (the button became something else before it drained) starts empty again.\n    if (fill.get() >= 1) fill.jump(0)\n    filling.current = animate(fill, 1, {\n      duration: (holdMs / 1000) * (1 - fill.get()),\n      ease: \"linear\",\n      onComplete: () => {\n        holding.current = false\n        setCounting(false)\n        if (touch) buzz(14)\n        // Full, it pops: a quick swell and back.\n        if (!reduced && button.current) animate(button.current, { scale: [0.97, 1.06, 1] }, { duration: 0.32, ease: ease.standard })\n        earned.current = true\n        button.current?.click()\n        spent.current = true\n        filling.current = animate(fill, 0, { duration: duration.considered, ease: ease.standard, delay: 0.15 })\n      },\n    })\n  }\n  const stopHold = () => {\n    if (!holding.current) return\n    holding.current = false\n    setCounting(false)\n    filling.current?.stop()\n    filling.current = animate(fill, 0, spring.glide)\n  }\n  const enter = reduced ? { opacity: 0 } : iconOnly ? { opacity: 0, scale: 0.8 } : { opacity: 0, y: 4 }\n\n  return (\n    <motion.button\n      ref={setRef}\n      data-slot=\"button\"\n      // Tells the kit's styles to leave the press to this component.\n      data-press=\"motion\"\n      {...props}\n      className={buttonVariants({\n        variant,\n        color,\n        size,\n        glow,\n        // Colors still change in CSS; transform is this component's, so CSS mustn't ease it too.\n        className: cn(\"transition-[color,background-color,border-color,box-shadow,opacity]\", holdMs > 0 && \"relative overflow-hidden\", className),\n      })}\n      aria-busy={loading || undefined}\n      aria-disabled={loading || props[\"aria-disabled\"]}\n      // preventDefault also stops a submit button from submitting its form. Held to confirm, only the click the hold\n      // earned goes through.\n      onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n        if (spent.current) {\n          spent.current = false\n          return event.preventDefault()\n        }\n        if (loading || (holdMs && !earned.current)) return event.preventDefault()\n        earned.current = false\n        onClick?.(event)\n      }}\n      onPointerDown={(event: React.PointerEvent<HTMLButtonElement>) => {\n        spent.current = false\n        onPointerDown?.(event)\n        if (event.button === 0) {\n          const box = event.currentTarget.getBoundingClientRect()\n          startHold(event.pointerType === \"touch\", { x: event.clientX - box.left, y: event.clientY - box.top })\n        }\n      }}\n      onPointerUp={(event: React.PointerEvent<HTMLButtonElement>) => {\n        onPointerUp?.(event)\n        stopHold()\n      }}\n      onPointerLeave={(event: React.PointerEvent<HTMLButtonElement>) => {\n        onPointerLeave?.(event)\n        stopHold()\n      }}\n      onPointerCancel={(event: React.PointerEvent<HTMLButtonElement>) => {\n        onPointerCancel?.(event)\n        stopHold()\n      }}\n      onKeyDown={(event: React.KeyboardEvent<HTMLButtonElement>) => {\n        if (!event.repeat) spent.current = false\n        onKeyDown?.(event)\n        if (holdMs && (event.key === \" \" || event.key === \"Enter\")) {\n          // The browser's own click on Enter would come before the hold is done.\n          event.preventDefault()\n          if (!event.repeat) startHold(false)\n        }\n      }}\n      onKeyUp={(event: React.KeyboardEvent<HTMLButtonElement>) => {\n        onKeyUp?.(event)\n        if (holdMs && (event.key === \" \" || event.key === \"Enter\")) {\n          event.preventDefault()\n          stopHold()\n        }\n      }}\n      custom={button}\n      variants={press}\n      whileTap={still ? undefined : \"pressed\"}\n      transition={spring.press}\n    >\n      {/*\n        The fill, behind the label, sweeping from the start to the end. Always there, so its drain keeps playing when the\n        button turns into something else after a hold (a Restore in its place): taken away, it would stop full.\n      */}\n      <motion.span\n        aria-hidden\n        className={cn(\"pointer-events-none absolute inset-0 origin-left bg-current opacity-15 rtl:origin-right\", (!holdMs || holdStyle !== \"fill\") && \"hidden\")}\n        style={{ scaleX: fill }}\n      />\n      {/* radial: a circle from where it was pressed, growing to cover the button. */}\n      <motion.span\n        ref={radial}\n        aria-hidden\n        className={cn(\"pointer-events-none absolute rounded-full bg-current opacity-15\", (!holdMs || holdStyle !== \"radial\") && \"hidden\")}\n        style={{ scale: fill }}\n      />\n      {/* ring: the button's edge, traced from its top-left corner round. */}\n      <svg aria-hidden className={cn(\"pointer-events-none absolute inset-0 size-full\", (!holdMs || holdStyle !== \"ring\") && \"hidden\")}>\n        {/* The path it will take, faint, while it's held; then the line drawn along it. */}\n        <motion.rect ref={ringTrack} x={1.5} y={1.5} fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeOpacity={0.15} style={{ opacity: ringShown }} />\n        <motion.rect ref={ring} x={1.5} y={1.5} fill=\"none\" stroke=\"currentColor\" strokeWidth={2} strokeLinecap=\"round\" style={{ pathLength: fill, opacity: ringShown }} />\n      </svg>\n      {/* A rounded line floating inside the foot, clear of the corners, over a faint track that shows while it's held. */}\n      <motion.span\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-x-3 bottom-1 h-0.5 overflow-hidden rounded-full\",\n          (!holdMs || (holdStyle !== \"bar\" && holdStyle !== \"countdown\")) && \"hidden\"\n        )}\n        style={{ opacity: ringShown }}\n      >\n        <span className=\"absolute inset-0 bg-current opacity-20\" />\n        <motion.span className=\"absolute inset-0 origin-left rounded-full bg-current rtl:origin-right\" style={{ scaleX: fill }} />\n      </motion.span>\n      {holdMs > 0 && <span className=\"sr-only\">{t(\"pressAndHold\")}</span>}\n      {/* The visible width, gliding; what's inside is measured at its own width and kept centred while it catches up. */}\n      <motion.span className=\"flex justify-center gap-[inherit]\" style={{ width }}>\n        <span ref={content} className=\"relative grid w-max shrink-0 gap-[inherit]\">\n          <motion.span\n            className={layer}\n            animate={{ opacity: loading || counting ? 0 : 1 }}\n            transition={{ duration: duration.fast, ease: ease.standard }}\n            aria-hidden={loading || undefined}\n          >\n            {morph && (typeof children === \"string\" || typeof children === \"number\") ? (\n              // With morph, a label that's only text changes letter by letter: the letters both labels have glide to their places.\n              <span className={layer}>\n                <TextMorph>{String(children)}</TextMorph>\n              </span>\n            ) : (\n              // popLayout lifts the outgoing label out of the flow at once, so the new one sets the width straight away.\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={key}\n                  className={layer}\n                  initial={enter}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}\n                  transition={\n                    reduced\n                      ? { duration: duration.fast }\n                      : iconOnly\n                        ? { ...spring.pop, opacity: { duration: duration.fast, ease: ease.enter } }\n                        : { duration: duration.standard, ease: ease.enter }\n                  }\n                >\n                  {children}\n                </motion.span>\n              </AnimatePresence>\n            )}\n          </motion.span>\n          {/* countdown: the seconds left take the label's place while it's held, keeping the button's width. */}\n          <AnimatePresence initial={false}>\n            {counting && (\n              <motion.span\n                key=\"countdown\"\n                aria-hidden\n                className={cn(layer, \"tabular-nums\")}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}\n                transition={{ duration: duration.fast, ease: ease.enter }}\n              >\n                <Odometer value={left} format={seconds} />\n              </motion.span>\n            )}\n          </AnimatePresence>\n          <AnimatePresence initial={false}>\n            {loading && (\n              <motion.span\n                key=\"loading\"\n                className={layer}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.8 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1, ease: ease.exit } }}\n                transition={reduced ? { duration: duration.fast } : { ...spring.pop, opacity: { duration: duration.fast, ease: ease.enter } }}\n              >\n                <Loader2 data-spinner=\"\" className=\"animate-spin motion-reduce:animate-[spin_1.5s_linear_infinite]\" aria-hidden />\n                {loadingText}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </span>\n      </motion.span>\n    </motion.button>\n  )\n}\n"}]}