tsx
import { useCallback, useRef, useState } from "react";
import {
  motion,
  AnimatePresence,
  useAnimationControls,
  useReducedMotion,
  type Variants,
} from "framer-motion";

// A bell that physically reacts to new activity: each arrival makes it RATTLE (a small, fast,
// decaying rotation from its top hanger — transform-only, so it stays crisp and on the compositor),
// the red badge BOUNCES with a slight lag so it reads as attached, and the count DIGIT pops in.
// "Animate" drives the count up (0 → 1 → … → 9 → "9+"); tapping the bell clears it to 0.

// First-stab arrival sound, synthesized with Web Audio (no asset needed). To use your own sound,
// replace this function's body with an HTMLAudioElement playing your file.
function useArrivalSound(enabled: boolean) {
  const ctxRef = useRef<AudioContext | null>(null);
  return useCallback(() => {
    if (!enabled || typeof window === "undefined") return;
    try {
      const AC =
        window.AudioContext ??
        (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
      if (!AC) return;
      let ctx = ctxRef.current;
      if (!ctx) ctx = ctxRef.current = new AC();
      if (ctx.state === "suspended") void ctx.resume();
      const now = ctx.currentTime;
      const tone = (freq: number, at: number, dur: number, peak: number) => {
        const osc = ctx!.createOscillator();
        const gain = ctx!.createGain();
        osc.type = "sine";
        osc.frequency.setValueAtTime(freq, now + at);
        gain.gain.setValueAtTime(0.0001, now + at);
        gain.gain.exponentialRampToValueAtTime(peak, now + at + 0.008); // fast attack
        gain.gain.exponentialRampToValueAtTime(0.0001, now + at + dur); // bell-ish decay
        osc.connect(gain).connect(ctx!.destination);
        osc.start(now + at);
        osc.stop(now + at + dur + 0.02);
      };
      // A soft, bright two-note "ding" (B5 → E6), kept at a low level.
      tone(987.77, 0, 0.18, 0.06);
      tone(1318.51, 0.08, 0.22, 0.05);
    } catch {
      /* AudioContext blocked/unavailable — fail silent */
    }
  }, [enabled]);
}

export default function NotificationBell() {
  const reduce = useReducedMotion() ?? false;
  const [count, setCount] = useState(0);
  const [muted, setMuted] = useState(false);
  const bellControls = useAnimationControls(); // replayable rattle
  const badgeControls = useAnimationControls(); // replayable bounce + follow-through
  const playArrival = useArrivalSound(!muted);

  const display = count > 9 ? "9+" : String(count); // cap at "9+" like a real badge

  // Bell rattle: a decaying oscillation about 0° (amplitude 8°, 6 swings, 0.5 decay), pivoting just
  // below the hanger (via transform-origin). Keyframes are evenly spaced (Framer Motion default).
  const rattle = {
    rotate: [0, -8, 4, -2, 1, -0.5, 0.25, 0],
    transition: { duration: 0.6, ease: "easeInOut" as const },
  };
  // Badge bounce + small rotation echo, started ~80ms late so it lags the bell (follow-through).
  const badgeReact = {
    scale: [1, 1.15, 0.94, 1.04, 1],
    rotate: [0, -6, 4, -1.5, 0],
    transition: { duration: 0.5, ease: "easeOut" as const, delay: 0.08, times: [0, 0.25, 0.5, 0.75, 1] },
  };

  const EASE_CLOSE = [0.4, 0, 0.2, 1] as const;
  // Badge appear (0 → 1): slides up out of the bell + pops; exit: pops/blurs out in place.
  const badgeV: Variants = {
    hidden: { scale: 0, opacity: 0, filter: "blur(2px)", x: -8, y: 12 },
    visible: {
      scale: 1,
      opacity: 1,
      filter: "blur(0px)",
      x: 0,
      y: 0,
      transition: reduce
        ? { duration: 0 }
        : {
            scale: { type: "spring", duration: 0.5, bounce: 0.5 },
            x: { type: "spring", duration: 0.5, bounce: 0.3 },
            y: { type: "spring", duration: 0.5, bounce: 0.3 },
            opacity: { duration: 0.3 },
            filter: { duration: 0.3 },
          },
    },
    exit: { scale: 0, opacity: 0, filter: "blur(2px)", transition: { duration: 0.18, ease: EASE_CLOSE } },
  };
  // Digit pop-in: each character rises in with a blur, staggered.
  const countContainer: Variants = {
    enter: { transition: { staggerChildren: reduce ? 0 : 0.06 } },
    center: { transition: { staggerChildren: reduce ? 0 : 0.06 } },
    exit: { transition: { staggerChildren: reduce ? 0 : 0.04, staggerDirection: -1 } },
  };
  const charV: Variants = {
    enter: { y: 8, opacity: 0, filter: "blur(2px)" },
    center: {
      y: 0,
      opacity: 1,
      filter: "blur(0px)",
      transition: reduce ? { duration: 0 } : { type: "spring", duration: 0.45, bounce: 0.4 },
    },
    exit: { y: -8, opacity: 0, filter: "blur(2px)", transition: { duration: 0.18, ease: EASE_CLOSE } },
  };

  function handleAnimate() {
    const had = count > 0;
    setCount((c) => c + 1);
    if (!reduce) {
      bellControls.start(rattle);
      if (had) badgeControls.start(badgeReact); // 0 → 1 is carried by the badge's own appear
    }
    playArrival();
  }

  function handleClear() {
    if (count === 0) return;
    setCount(0);
  }

  return (
    <div className="nb-stage">
      <style>{styles}</style>

      {/* The bell is a real button: tap to clear, with a tactile press. */}
      <motion.button
        type="button"
        className="nb-bell-btn"
        onClick={handleClear}
        whileTap={reduce ? undefined : { scale: 0.92 }}
        transition={{ type: "spring", duration: 0.25, bounce: 0.4 }}
        aria-label={
          count === 0 ? "Notifications, no new items" : `Notifications, ${count} new — activate to clear`
        }
      >
        <motion.span
          className="nb-rotor"
          animate={bellControls}
          style={{ transformOrigin: "50% 24%" }} // tuned pivot — a touch below the hanger for a looser swing
        >
          <BellIcon />
        </motion.span>

        <AnimatePresence>
          {count > 0 && (
            <motion.span key="badge" className="nb-badge" variants={badgeV} initial="hidden" animate="visible" exit="exit">
              <motion.span className="nb-badge-inner" animate={badgeControls}>
                <span className="nb-count-clip">
                  <AnimatePresence mode="popLayout" initial={false}>
                    <motion.span
                      key={display}
                      className="nb-count"
                      variants={countContainer}
                      initial="enter"
                      animate="center"
                      exit="exit"
                    >
                      {display.split("").map((ch, i) => (
                        <motion.span key={i} className="nb-char" variants={charV}>
                          {ch}
                        </motion.span>
                      ))}
                    </motion.span>
                  </AnimatePresence>
                </span>
              </motion.span>
            </motion.span>
          )}
        </AnimatePresence>
      </motion.button>

      <div className="nb-controls">
        <button type="button" className="nb-animate" onClick={handleAnimate}>
          Animate
        </button>
        <button
          type="button"
          className="nb-mute"
          onClick={() => setMuted((m) => !m)}
          aria-pressed={muted}
          aria-label={muted ? "Unmute notification sound" : "Mute notification sound"}
          title={muted ? "Unmute" : "Mute"}
        >
          <SpeakerIcon muted={muted} />
        </button>
      </div>

      <span className="nb-sr" aria-live="polite">
        {count === 0 ? "" : `${count} new ${count === 1 ? "notification" : "notifications"}`}
      </span>
    </div>
  );
}

function BellIcon() {
  return (
    <svg viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true">
      <path d="M16 17C16 19.2091 14.2091 21 12 21C9.79086 21 8 19.2091 8 17" strokeLinejoin="round" />
      <path
        d="M20 16.191C20 16.6378 19.6378 17 19.191 17H4.80902C4.36221 17 4 16.6378 4 16.191C4 16.0654 4.02924 15.9415 4.08541 15.8292L5.21846 13.5631C5.40413 13.1917 5.51071 12.7859 5.53144 12.3712L5.70037 8.99251C5.86822 5.63561 8.6389 3 12 3C15.3611 3 18.1318 5.63561 18.2996 8.99251L18.4686 12.3712C18.4893 12.7859 18.5959 13.1917 18.7815 13.5631L19.9146 15.8292C19.9708 15.9415 20 16.0654 20 16.191Z"
        strokeLinecap="square"
        strokeLinejoin="round"
      />
    </svg>
  );
}

function SpeakerIcon({ muted }: { muted: boolean }) {
  return (
    <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 7.99999H5.2759C5.74377 7.99999 6.19684 7.83596 6.55627 7.53643L10.3598 4.36681C11.0111 3.82403 12 4.28719 12 5.13503V18.8649C12 19.7128 11.0111 20.1759 10.3598 19.6332L6.55627 16.4635C6.19684 16.164 5.74377 16 5.2759 16H4C2.89543 16 2 15.1046 2 14V9.99999C2 8.89542 2.89543 7.99999 4 7.99999Z" />
      {muted ? (
        <path d="M21.5 9.99999L19.3787 12.1213M19.3787 12.1213L17.2574 14.2426M19.3787 12.1213L17.2574 9.99999M19.3787 12.1213L21.5 14.2426" />
      ) : (
        <path d="M15.8891 8.11132C16.8844 9.10662 17.5 10.4816 17.5 12.0004C17.5 13.5192 16.8844 14.8942 15.8891 15.8895" />
      )}
    </svg>
  );
}

const styles = `
.nb-stage {
  /* A clean notification red that holds in both themes; white digits. The ring uses the page
     background so the badge reads as floating above the bell. */
  --nb-badge-bg: light-dark(#ff3b30, #ff453a);
  --nb-badge-text: #ffffff;
  --nb-btn-bg: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.08));
  --nb-btn-bg-hover: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.12));
  --nb-ctl-idle: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.5));
  --nb-ctl-hover: light-dark(rgba(0, 0, 0, 0.72), rgba(255, 255, 255, 0.85));
  --nb-focus: light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.65));

  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
  font-family: var(--font-sans, "Geist Sans", system-ui, sans-serif);
  color: var(--foreground);
}
.nb-stage *, .nb-stage *::before, .nb-stage *::after { box-sizing: border-box; }

.nb-bell-btn {
  position: relative; /* anchors the badge */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 6px;
  margin: 0;
  border: none;
  background: transparent;
  color: var(--foreground);
  border-radius: 14px;
  cursor: pointer;
  outline: none;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
}
.nb-bell-btn:focus-visible { box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--nb-focus); }

.nb-rotor { display: inline-flex; will-change: transform; }

.nb-badge {
  position: absolute;
  top: 1px;
  right: 1px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--nb-badge-bg);
  box-shadow: 0 0 0 2px var(--background); /* single color — safe with a var (not a light-dark list) */
  pointer-events: none;
  will-change: transform, opacity, filter;
}
.nb-badge-inner { display: inline-flex; align-items: center; justify-content: center; will-change: transform; }
.nb-count-clip { display: inline-flex; align-items: center; justify-content: center; height: 14px; overflow: hidden; }
.nb-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--nb-badge-text);
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.2px;
}
.nb-char { display: inline-block; will-change: transform, opacity, filter; }

.nb-controls { display: flex; align-items: center; gap: 8px; }
.nb-animate {
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  color: var(--foreground);
  background: var(--nb-btn-bg);
  border: none;
  border-radius: 999px;
  padding: 9px 18px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
  transition: transform 140ms cubic-bezier(0.4, 0, 0.2, 1), background-color 140ms ease;
}
@media (hover: hover) { .nb-animate:hover { background: var(--nb-btn-bg-hover); } }
.nb-animate:active { transform: scale(0.97); }
.nb-animate:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--nb-focus); }

.nb-mute {
  display: inline-grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: none;
  background: transparent;
  border-radius: 9px;
  color: var(--nb-ctl-idle);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation;
  transition: color 160ms ease, background 160ms ease;
}
@media (hover: hover) { .nb-mute:hover { color: var(--nb-ctl-hover); background: var(--hover-bg); } }
.nb-mute:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--background), 0 0 0 4px var(--nb-focus); }

.nb-sr {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0);
  white-space: nowrap; border: 0;
}

@media (prefers-reduced-motion: reduce) {
  .nb-stage *, .nb-stage *::before, .nb-stage *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
`;
Notification bell