{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"like-button","type":"registry:component","title":"Like button","description":"Pops, bursts and rolls the count when you like.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/like-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  animate,\n  AnimatePresence,\n  motion,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The squash: quick, so the heart visibly gives under the press.\nconst SQUASH = { duration: 0.1, ease: EASE_OUT } as const;\n// Launched from the 0.8 squash with this kick, the spring overshoots to\n// about 1.2 at ~75ms, dips once to ~0.95 and settles.\nconst POP = { type: \"spring\", stiffness: 500, damping: 20 } as const;\nconst POP_VELOCITY = 11;\n// Unliking only ever dips, so this is heavily damped: down to ~0.94, back.\nconst DIP = { type: \"spring\", stiffness: 500, damping: 30 } as const;\nconst DIP_VELOCITY = -3;\nconst SETTLE = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\nconst PARTICLES = 7;\n// Longer than a UI transition on purpose: it's a rare celebration that\n// never blocks input, and particles that vanish in 200ms read as a glitch.\nconst BURST = { duration: 0.45, ease: EASE_OUT } as const;\n// Lets the heart start swelling before the particles leave it.\nconst BURST_DELAY = 0.04;\n\ntype Particle = { angle: number; distance: number; size: number };\ntype Burst = { id: number; particles: Particle[] };\n\nfunction rollBurst(id: number): Burst {\n  const offset = Math.random() * 360;\n  return {\n    id,\n    particles: Array.from({ length: PARTICLES }, (_, i) => ({\n      // Evenly spread, then jittered so no two bursts look stamped.\n      angle: offset + (i * 360) / PARTICLES + (Math.random() - 0.5) * 20,\n      distance: 18 + Math.random() * 6,\n      size: Math.random() < 0.5 ? 4 : 3,\n    })),\n  };\n}\n\nexport function LikeButton({\n  liked,\n  count,\n  onLikedChange,\n  className,\n}: {\n  liked: boolean;\n  count: number;\n  onLikedChange: (liked: boolean) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const heart = useRef<HTMLSpanElement>(null);\n  const scale = useRef<AnimationPlaybackControls>(undefined);\n  const pressed = useRef(false);\n  const nextBurst = useRef(0);\n  const [bursts, setBursts] = useState<Burst[]>([]);\n\n  useEffect(() => () => scale.current?.stop(), []);\n\n  const scaleHeart = (\n    to: number | number[],\n    transition: Parameters<typeof animate>[2],\n  ) => {\n    if (reduceMotion || !heart.current) return;\n    scale.current = animate(heart.current, { scale: to }, transition);\n  };\n\n  const release = () => {\n    if (!pressed.current) return;\n    pressed.current = false;\n    scaleHeart(1, SETTLE);\n  };\n\n  const toggle = () => {\n    const next = !liked;\n    const fromPointer = pressed.current;\n    pressed.current = false;\n    onLikedChange(next);\n    if (reduceMotion) return;\n    if (next) {\n      // Keyboard presses skip the squash, so start from it here instead;\n      // the same pop plays either way.\n      scaleHeart(fromPointer ? 1 : [0.8, 1], {\n        ...POP,\n        velocity: POP_VELOCITY,\n      });\n      const burst = rollBurst(nextBurst.current++);\n      setBursts((all) => [...all, burst]);\n    } else if (fromPointer) {\n      scaleHeart(1, SETTLE);\n    } else {\n      scaleHeart(1, { ...DIP, velocity: DIP_VELOCITY });\n    }\n  };\n\n  const noun = count === 1 ? \"like\" : \"likes\";\n\n  return (\n    <button\n      type=\"button\"\n      aria-pressed={liked}\n      aria-label={`Like, ${count} ${noun}`}\n      onPointerDown={(e) => {\n        if (e.button !== 0) return;\n        pressed.current = true;\n        // Liking squashes hard to wind up the pop; unliking barely gives.\n        scaleHeart(liked ? 0.9 : 0.8, SQUASH);\n      }}\n      onPointerLeave={release}\n      onPointerCancel={release}\n      onClick={toggle}\n      className={cn(\n        \"group flex h-9 touch-manipulation items-center gap-1.5 rounded-full bg-muted pr-3.5 pl-3 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-none\",\n        className,\n      )}\n    >\n      <span className=\"relative flex size-5 items-center justify-center\">\n        {/* Behind the heart, so particles appear to leave from inside it. */}\n        <span aria-hidden className=\"pointer-events-none absolute inset-0\">\n          {bursts.map((burst) => (\n            <BurstView\n              key={burst.id}\n              burst={burst}\n              onDone={() =>\n                setBursts((all) => all.filter((b) => b.id !== burst.id))\n              }\n            />\n          ))}\n        </span>\n        <span ref={heart} className=\"relative block\">\n          <svg\n            viewBox=\"0 0 24 24\"\n            className={cn(\n              \"block size-5 fill-current transition-[color,fill-opacity] ease-out\",\n              liked\n                ? \"text-destructive [fill-opacity:1] duration-150\"\n                : \"text-muted-foreground [fill-opacity:0] duration-100 group-hover:text-foreground\",\n            )}\n            stroke=\"currentColor\"\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            aria-hidden\n          >\n            <path d=\"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z\" />\n          </svg>\n        </span>\n      </span>\n      <Count\n        value={count}\n        // Liking raises the count, so digits roll up; unliking rolls down.\n        direction={liked ? 1 : -1}\n        reduceMotion={reduceMotion}\n      />\n    </button>\n  );\n}\n\nfunction BurstView({ burst, onDone }: { burst: Burst; onDone: () => void }) {\n  return (\n    <>\n      {/* Starts inside the heart rather than from nothing. */}\n      <motion.span\n        className=\"absolute inset-0 rounded-full border-[1.5px] border-destructive\"\n        initial={{ scale: 0.6, opacity: 0.6 }}\n        animate={{ scale: 1.8, opacity: 0 }}\n        transition={{ duration: 0.35, ease: EASE_OUT }}\n      />\n      {burst.particles.map((p, i) => {\n        const rad = (p.angle * Math.PI) / 180;\n        const [cos, sin] = [Math.cos(rad), Math.sin(rad)];\n        return (\n          <motion.span\n            key={i}\n            className=\"absolute top-1/2 left-1/2 rounded-full bg-destructive\"\n            style={{\n              width: p.size,\n              height: p.size,\n              marginLeft: -p.size / 2,\n              marginTop: -p.size / 2,\n            }}\n            // Leaves from the heart's edge, not its center.\n            initial={{ x: cos * 6, y: sin * 6, scale: 1, opacity: 1 }}\n            animate={{\n              x: cos * p.distance,\n              y: sin * p.distance,\n              scale: 0.4,\n              // Holds full strength for the first half of the flight.\n              opacity: [1, 1, 0],\n            }}\n            transition={{\n              ...BURST,\n              delay: BURST_DELAY,\n              opacity: { ...BURST, delay: BURST_DELAY, times: [0, 0.5, 1] },\n            }}\n            onAnimationComplete={i === 0 ? onDone : undefined}\n          />\n        );\n      })}\n    </>\n  );\n}\n\ntype Roll = { direction: number; reduceMotion: boolean | null };\n\nconst ROLL = {\n  enter: ({ direction, reduceMotion }: Roll) => ({\n    y: reduceMotion ? \"0%\" : `${direction * 100}%`,\n    opacity: 0,\n  }),\n  center: {\n    y: \"0%\",\n    opacity: 1,\n    transition: { duration: 0.25, ease: EASE_OUT },\n  },\n  exit: ({ direction, reduceMotion }: Roll) => ({\n    y: reduceMotion ? \"0%\" : `${direction * -100}%`,\n    opacity: 0,\n    transition: { duration: 0.2, ease: EASE_OUT },\n  }),\n};\n\nfunction Count({\n  value,\n  direction,\n  reduceMotion,\n}: {\n  value: number;\n  direction: number;\n  reduceMotion: boolean | null;\n}) {\n  const digits = String(value).split(\"\");\n  const custom = { direction, reduceMotion };\n  return (\n    <span aria-hidden className=\"flex tabular-nums\">\n      {digits.map((digit, i) => (\n        // Keyed by place value from the right, so only digits that change\n        // roll, and ones stay ones when the count gains a digit.\n        <span\n          key={digits.length - i}\n          className=\"inline-grid overflow-hidden\"\n        >\n          <AnimatePresence initial={false} custom={custom}>\n            <motion.span\n              key={digit}\n              custom={custom}\n              variants={ROLL}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              className=\"col-start-1 row-start-1\"\n            >\n              {digit}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n\nexport default function LikeButtonDemo() {\n  const [liked, setLiked] = useState(false);\n  return (\n    <LikeButton\n      liked={liked}\n      count={liked ? 129 : 128}\n      onLikedChange={setLiked}\n    />\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/like-button"}