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;
}
}
`;