{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"send-button","type":"registry:component","title":"Send button","description":"The plane takes off along a curve, and a check lands in its place.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/send-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Status = \"idle\" | \"sending\" | \"sent\";\n\n// 450ms, past the usual 300ms cap on purpose: the flight is the confirmation\n// itself and has to travel ~100px along a curve. Anything shorter reads as\n// the plane vanishing rather than leaving.\nconst FLIGHT = 0.45;\n// Dips just below 0 before it goes, so the plane crouches back ~3px first\n// (anticipation), then accelerates out: an exit, so it ends fast.\nconst FLIGHT_EASE = [0.45, -0.25, 0.8, 0.4] as const;\n// \"Sent\" arrives while the plane is still fading out up top, so the button\n// never sits empty; the overlap is what makes the hand-off read as one move.\nconst SENT_AT = 300;\n// Long enough to read \"Sent\", short enough to be ready for the next message.\nconst RESET_AFTER = 1600;\nconst SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// Quadratic curve in px, relative to where the plane rests. The first control\n// point sits level with the start so the path leaves horizontally and the\n// plane never snaps its nose on the first frame.\nconst P1 = { x: 36, y: 0 };\nconst P2 = { x: 84, y: -52 };\nconst along = (t: number) => ({\n  x: 2 * (1 - t) * t * P1.x + t * t * P2.x,\n  y: 2 * (1 - t) * t * P1.y + t * t * P2.y,\n});\n// The curve's tangent, so the nose always points where it is heading.\nconst heading = (t: number) =>\n  (Math.atan2(\n    2 * (1 - t) * P1.y + 2 * t * (P2.y - P1.y),\n    2 * (1 - t) * P1.x + 2 * t * (P2.x - P1.x),\n  ) *\n    180) /\n  Math.PI;\n\nexport function SendButton({\n  onSend,\n  label = \"Send\",\n  sentLabel = \"Sent\",\n  variant = \"solid\",\n  iconOnly = false,\n  className,\n}: {\n  onSend?: () => void;\n  label?: string;\n  sentLabel?: string;\n  variant?: \"solid\" | \"subtle\";\n  iconOnly?: boolean;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [status, setStatus] = useState<Status>(\"idle\");\n  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const flight = useRef<AnimationPlaybackControls>(undefined);\n\n  const t = useMotionValue(0);\n  const x = useTransform(t, (v) => along(v).x);\n  const y = useTransform(t, (v) => along(v).y);\n  const rotate = useTransform(t, heading);\n  // Shrinks a little as it gets further away, and fades over the last 45%.\n  const scale = useTransform(t, [0, 1], [1, 0.75]);\n  const opacity = useTransform(t, [0.55, 1], [1, 0]);\n\n  useEffect(\n    () => () => {\n      timers.current.forEach(clearTimeout);\n      flight.current?.stop();\n    },\n    [],\n  );\n\n  const send = () => {\n    // Ignore repeat clicks until the button is back to Send.\n    if (status !== \"idle\") return;\n    onSend?.();\n    setStatus(\"sending\");\n    if (!reduceMotion) {\n      flight.current = animate(t, 1, { duration: FLIGHT, ease: FLIGHT_EASE });\n    }\n    timers.current = [\n      setTimeout(() => setStatus(\"sent\"), reduceMotion ? 0 : SENT_AT),\n      setTimeout(() => {\n        // Home again, hidden; the plane then grows back in place.\n        t.jump(0);\n        setStatus(\"idle\");\n      }, RESET_AFTER),\n    ];\n  };\n\n  const planeHome = status !== \"sent\";\n\n  return (\n    <>\n      <button\n        type=\"button\"\n        // A fixed name; the live region below reports \"Sent\".\n        aria-label={label}\n        aria-disabled={status !== \"idle\" || undefined}\n        onClick={send}\n        className={cn(\n          \"inline-flex h-11 touch-manipulation items-center justify-center gap-2 rounded-full text-[15px] font-medium outline-hidden select-none\",\n          \"transition-[scale,background-color,opacity] duration-150 ease-out active:scale-[0.96] motion-reduce:transition-[background-color,opacity]\",\n          \"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n          variant === \"solid\"\n            ? \"bg-foreground text-background hover:bg-foreground/90\"\n            : \"bg-muted text-foreground shadow-raised hover:bg-background\",\n          // 4px less on the icon side: the plane's tail is mostly air.\n          iconOnly ? \"w-11\" : \"pr-5 pl-4\",\n          status !== \"idle\" && \"cursor-default\",\n          className,\n        )}\n      >\n        {/* The plane's mass sits left of its box center, so it gets a 1px\n            nudge right to look centered on its own. */}\n        <span aria-hidden className={cn(\"grid\", iconOnly && \"translate-x-px\")}>\n          <motion.span\n            className=\"col-start-1 row-start-1 flex\"\n            initial={false}\n            animate={\n              planeHome\n                ? { scale: 1, opacity: 1, filter: \"blur(0px)\" }\n                : reduceMotion\n                  ? { scale: 1, opacity: 0, filter: \"blur(0px)\" }\n                  : { scale: 0.25, opacity: 0, filter: \"blur(4px)\" }\n            }\n            // Leaving: wait out the flight's last 150ms, then drop out\n            // invisibly (the plane has already faded) so it can regrow here.\n            transition={\n              planeHome\n                ? SWAP\n                : reduceMotion\n                  ? { duration: 0.15 }\n                  : { duration: 0, delay: FLIGHT - SENT_AT / 1000 }\n            }\n          >\n            <motion.svg\n              viewBox=\"0 0 24 24\"\n              className=\"size-5\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.75}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              style={{ x, y, rotate, scale, opacity }}\n            >\n              <path d=\"M3.75 4.25 20.25 12 3.75 19.75 6.5 12Z\" />\n              <path d=\"M6.5 12h5.25\" />\n            </motion.svg>\n          </motion.span>\n          <motion.svg\n            viewBox=\"0 0 24 24\"\n            className=\"col-start-1 row-start-1 size-5\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            initial={false}\n            // Slides in rightward, the way the plane left, so the check reads\n            // as the same motion finishing rather than a new thing popping in.\n            animate={\n              !planeHome\n                ? { x: 0, opacity: 1, filter: \"blur(0px)\" }\n                : reduceMotion\n                  ? { x: 0, opacity: 0, filter: \"blur(0px)\" }\n                  : { x: -8, opacity: 0, filter: \"blur(4px)\" }\n            }\n            transition={\n              !planeHome ? SWAP : { duration: 0.12, ease: [0.23, 1, 0.32, 1] }\n            }\n          >\n            <path d=\"m5 12.5 4.5 4.5L19 7\" />\n          </motion.svg>\n        </span>\n\n        {!iconOnly && (\n          // Shared cell: the button keeps the longer word's width.\n          <span aria-hidden className=\"grid\">\n            <Word visible={status === \"idle\"}>{label}</Word>\n            <Word visible={status === \"sent\"}>{sentLabel}</Word>\n          </span>\n        )}\n      </button>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {status === \"sent\" ? sentLabel : \"\"}\n      </span>\n    </>\n  );\n}\n\n// Enters over 200ms, leaves in 100ms, so \"Send\" is gone the moment the plane\n// moves and never overlaps \"Sent\".\nfunction Word({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <span\n      className={cn(\n        \"col-start-1 row-start-1 transition-[opacity,filter,translate] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-[opacity]\",\n        visible\n          ? \"translate-x-0 opacity-100 blur-[0px] duration-200\"\n          : \"-translate-x-1 opacity-0 blur-[4px] duration-100 motion-reduce:translate-x-0 motion-reduce:blur-[0px]\",\n      )}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport default function SendButtonDemo() {\n  return (\n    // Headroom above and to the right: the plane flies ~84px right and\n    // ~52px up, and should stay inside the stage while it fades.\n    <div className=\"flex w-[min(420px,100%)] flex-wrap items-center justify-center gap-4 pt-16 pr-12\">\n      <SendButton />\n      <SendButton variant=\"subtle\" />\n      <SendButton variant=\"subtle\" iconOnly />\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/send-button"}