{
  "name": "bell-button",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Bell Button\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The bell and its clapper are two pendulums on the same pivot. Nothing\n// couples them but collisions: the swinging bell catches the clapper with\n// its lip, flings it across, and it strikes the other side. Angles in deg,\n// time in seconds.\nconst STEP = 1 / 240;\nconst BELL_STIFFNESS = 380;\nconst BELL_DAMPING = 5;\nconst CLAPPER_STIFFNESS = 150;\nconst CLAPPER_DAMPING = 4;\n// How far the clapper can swing inside the bell before it hits the lip.\nconst LIP = 13;\nconst RESTITUTION = 0.45;\n// Share of a hit that goes into the bell, which is much heavier.\nconst RECOIL = 0.18;\n// Impacts harder than this ring out (deg/s, relative).\nconst STRIKE_SPEED = 260;\nconst RING_KICK = 600;\nconst NUDGE_KICK = 160;\nconst MUTE_KICK = 300;\nconst REST = 0.05;\nconst PIVOT = { x: 12, y: 2.5 };\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\nfunction haptic(ms: number) {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(ms);\n  }\n}\n\n/** A number whose digits roll to their new value, like a counter. */\nfunction RollingNumber({ value }: { value: number }) {\n  const text = value.toLocaleString(\"en-US\");\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex tabular-nums\">\n      {text.split(\"\").map((char, i) => {\n        // Keyed from the right, so the ones stay the ones when a digit is added.\n        const key = text.length - i;\n        if (char < \"0\" || char > \"9\") {\n          return <span key={key}>{char}</span>;\n        }\n        const digit = Number(char);\n        return (\n          <span className=\"relative inline-block h-5 overflow-hidden\" key={key}>\n            <span\n              className=\"flex flex-col transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n              style={{ transform: `translateY(${-digit * 10}%)` }}\n            >\n              {\"0123456789\".split(\"\").map((d) => (\n                <span className=\"h-5 leading-5\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nexport interface BellButtonProps {\n  /** `default` shows the label and count, `compact` the label, `icon` the bell alone. */\n  variant?: \"default\" | \"compact\" | \"icon\";\n  /** Controlled state. */\n  subscribed?: boolean;\n  defaultSubscribed?: boolean;\n  onSubscribedChange?: (subscribed: boolean) => void;\n  /** Subscribers, not counting this one. */\n  count?: number;\n  label?: string;\n  subscribedLabel?: string;\n  className?: string;\n}\n\nexport default function BellButton({\n  variant = \"default\",\n  subscribed: subscribedProp,\n  defaultSubscribed = false,\n  onSubscribedChange,\n  count = 1284,\n  label = \"Notify me\",\n  subscribedLabel = \"Subscribed\",\n  className,\n}: BellButtonProps) {\n  const [internal, setInternal] = useState(defaultSubscribed);\n  const subscribed = subscribedProp ?? internal;\n\n  const bellRef = useRef<SVGGElement>(null);\n  const clapperRef = useRef<SVGPathElement>(null);\n  const leftWaveRef = useRef<SVGPathElement>(null);\n  const rightWaveRef = useRef<SVGPathElement>(null);\n\n  const sim = useRef({\n    bell: 0,\n    bellSpeed: 0,\n    clapper: 0,\n    clapperSpeed: 0,\n    // Muffled: the clapper is held against the bell and can't strike.\n    muffled: false,\n    raf: 0,\n    last: 0,\n    carry: 0,\n    lastNudge: 0,\n    reduceMotion: false,\n  });\n\n  const draw = useCallback(() => {\n    const s = sim.current;\n    bellRef.current?.setAttribute(\n      \"transform\",\n      `rotate(${s.bell.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`\n    );\n    clapperRef.current?.setAttribute(\n      \"transform\",\n      `rotate(${s.clapper.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`\n    );\n  }, []);\n\n  // Two short arcs flare out on the side that was struck.\n  const flare = useCallback((side: 1 | -1, strength: number) => {\n    const wave = side > 0 ? rightWaveRef.current : leftWaveRef.current;\n    if (!wave?.animate) {\n      return;\n    }\n    const peak = Math.min(1, 0.45 + strength * 0.55);\n    wave.animate(\n      [\n        { opacity: peak, transform: \"translateX(0px)\" },\n        { opacity: 0, transform: `translateX(${side * 3}px)` },\n      ],\n      { duration: 420, easing: EASE_OUT }\n    );\n  }, []);\n\n  // Advances both pendulums one step and resolves a clapper hit.\n  const step = useCallback(() => {\n    const s = sim.current;\n    s.bellSpeed +=\n      (-s.bell * BELL_STIFFNESS - s.bellSpeed * BELL_DAMPING) * STEP;\n    s.bell += s.bellSpeed * STEP;\n    if (s.muffled) {\n      s.clapper = s.bell;\n      s.clapperSpeed = s.bellSpeed;\n      return;\n    }\n    s.clapperSpeed +=\n      (-s.clapper * CLAPPER_STIFFNESS - s.clapperSpeed * CLAPPER_DAMPING) *\n      STEP;\n    s.clapper += s.clapperSpeed * STEP;\n    const relative = s.clapper - s.bell;\n    if (Math.abs(relative) <= LIP) {\n      return;\n    }\n    const side = relative > 0 ? 1 : -1;\n    const impact = (s.clapperSpeed - s.bellSpeed) * side;\n    s.clapper = s.bell + side * LIP;\n    if (impact <= 0) {\n      return;\n    }\n    s.clapperSpeed -= side * impact * (1 + RESTITUTION);\n    s.bellSpeed += side * impact * RECOIL;\n    if (impact > STRIKE_SPEED) {\n      // The clapper hits the bell's right wall when it swings right.\n      flare(side as 1 | -1, impact / 1600);\n      haptic(6);\n    }\n  }, [flare]);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP;\n      s.last = now;\n      s.carry += elapsed;\n      while (s.carry >= STEP) {\n        step();\n        s.carry -= STEP;\n      }\n      draw();\n      const energy =\n        Math.abs(s.bell) +\n        Math.abs(s.bellSpeed) * 0.02 +\n        Math.abs(s.clapper) +\n        Math.abs(s.clapperSpeed) * 0.02;\n      if (energy < REST) {\n        s.bell = 0;\n        s.bellSpeed = 0;\n        s.clapper = 0;\n        s.clapperSpeed = 0;\n        s.last = 0;\n        draw();\n        return;\n      }\n      s.raf = requestAnimationFrame(tick);\n    },\n    [draw, step]\n  );\n\n  const kick = useCallback(\n    (speed: number) => {\n      const s = sim.current;\n      if (s.reduceMotion) {\n        return;\n      }\n      s.bellSpeed += speed;\n      if (!s.raf) {\n        s.raf = requestAnimationFrame(tick);\n      }\n    },\n    [tick]\n  );\n\n  useEffect(() => {\n    const s = sim.current;\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduceMotion = query.matches;\n    const onChange = () => {\n      s.reduceMotion = query.matches;\n    };\n    query.addEventListener(\"change\", onChange);\n    return () => {\n      query.removeEventListener(\"change\", onChange);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, []);\n\n  const handleClick = () => {\n    const next = !subscribed;\n    if (subscribedProp === undefined) {\n      setInternal(next);\n    }\n    onSubscribedChange?.(next);\n    const s = sim.current;\n    if (next) {\n      s.muffled = false;\n      // Swing the bell away from the clapper so the first hit rings.\n      kick(s.bellSpeed >= 0 ? RING_KICK : -RING_KICK);\n      if (s.reduceMotion) {\n        flare(1, 0.6);\n        flare(-1, 0.6);\n      }\n      haptic(10);\n    } else {\n      // Off: a dull shake, the clapper held still against the bell.\n      s.muffled = true;\n      kick(-MUTE_KICK);\n    }\n  };\n\n  const handlePointerEnter = (event: PointerEvent) => {\n    // A brush past the bell sets it swaying a little. Mouse only: on touch,\n    // enter fires right before the tap.\n    const s = sim.current;\n    if (event.pointerType !== \"mouse\" || s.muffled) {\n      return;\n    }\n    if (event.timeStamp - s.lastNudge < 700) {\n      return;\n    }\n    s.lastNudge = event.timeStamp;\n    kick(s.bellSpeed >= 0 ? NUDGE_KICK : -NUDGE_KICK);\n  };\n\n  // Starting unsubscribed means the bell is muted.\n  useEffect(() => {\n    sim.current.muffled = !subscribed;\n  }, [subscribed]);\n\n  return (\n    <button\n      aria-label={variant === \"icon\" ? label : undefined}\n      aria-pressed={subscribed}\n      className={cn(\n        \"inline-flex h-11 select-none items-center gap-2.5 rounded-full border font-medium text-sm outline-none transition-colors duration-200 ease-out focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:ring-offset-zinc-950\",\n        variant === \"default\" && \"pr-1.5 pl-4\",\n        variant === \"compact\" && \"px-4\",\n        variant === \"icon\" && \"w-11 justify-center\",\n        subscribed\n          ? \"border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-800 active:bg-zinc-700 dark:border-zinc-100 dark:bg-zinc-100 dark:text-zinc-900 dark:active:bg-zinc-300 dark:hover:bg-zinc-200\"\n          : \"border-zinc-200 bg-white text-zinc-900 hover:bg-zinc-50 active:bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100 dark:active:bg-zinc-800 dark:hover:bg-zinc-900\",\n        className\n      )}\n      onClick={handleClick}\n      onPointerEnter={handlePointerEnter}\n      type=\"button\"\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-5 overflow-visible\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={2}\n        viewBox=\"0 0 24 24\"\n      >\n        <path\n          className=\"opacity-0\"\n          d=\"M21.5 4.5c1.2 1.6 1.8 3.4 1.8 5.5\"\n          ref={rightWaveRef}\n        />\n        <path\n          className=\"opacity-0\"\n          d=\"M2.5 4.5C1.3 6.1.7 7.9.7 10\"\n          ref={leftWaveRef}\n        />\n        <path d=\"M10.27 21a2 2 0 0 0 3.46 0\" ref={clapperRef} />\n        <g ref={bellRef}>\n          <path\n            className={cn(\n              \"transition-[fill] duration-200 ease-out\",\n              subscribed ? \"fill-current\" : \"fill-transparent\"\n            )}\n            d=\"M3.26 15.33A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.41 13.96 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.41 5.96-2.74 7.33\"\n          />\n          {/* The mute slash, drawn across when off. */}\n          <path\n            className=\"transition-[stroke-dashoffset] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n            d=\"M3 3l18 18\"\n            strokeDasharray=\"26\"\n            strokeDashoffset={subscribed ? 26 : 0}\n          />\n        </g>\n      </svg>\n\n      {variant === \"icon\" ? null : (\n        <span className=\"relative grid\">\n          {[\n            { key: \"off\", on: !subscribed, text: label },\n            { key: \"on\", on: subscribed, text: subscribedLabel },\n          ].map((line) => (\n            <span\n              aria-hidden={!line.on}\n              className={cn(\n                \"col-start-1 row-start-1 whitespace-nowrap transition-[opacity,filter] duration-200 ease-out\",\n                line.on ? \"opacity-100 blur-0\" : \"opacity-0 blur-[2px]\"\n              )}\n              key={line.key}\n            >\n              {line.text}\n            </span>\n          ))}\n        </span>\n      )}\n\n      {variant === \"default\" ? (\n        <span\n          className={cn(\n            \"rounded-full px-2.5 py-1 text-xs transition-colors duration-200 ease-out\",\n            subscribed\n              ? \"bg-white/15 text-white/80 dark:bg-zinc-900/10 dark:text-zinc-900/70\"\n              : \"bg-zinc-100 text-zinc-500 dark:bg-zinc-900 dark:text-zinc-400\"\n          )}\n        >\n          <RollingNumber value={count + (subscribed ? 1 : 0)} />\n          <span className=\"sr-only\">\n            {(count + (subscribed ? 1 : 0)).toLocaleString(\"en-US\")} subscribers\n          </span>\n        </span>\n      ) : null}\n    </button>\n  );\n}\n",
      "path": "/components/kokonutui/bell-button.tsx",
      "target": "components/kokonutui/bell-button.tsx"
    }
  ]
}