"use client"; /** * BellToggle, from https://motion-brew.com/components/bell-toggle * * One self contained file: the component and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. */ import * as React from "react"; import { useGSAP } from "@gsap/react"; import { type ClassValue, clsx } from "clsx"; import gsap from "gsap"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // BellToggle /** * BellToggle * * Trigger: press. A pill toggle for notifications. Off, it is a soft gray * pill with a muted bell crossed by a slash. Pressing it turns it on: the * pill unfurls wider and turns white, the slash draws itself away, the * bell rings (it swings on its hanger while the clapper swings the other way * a beat behind, and two sound waves draw out on each side and fade), and the * label crossfades to the on text. Pressing again turns it off: the slash * strokes back across the bell, the bell gives a small droop, the label * crossfades back and the pill folds down to its gray rest. Hovering the on * pill gives the bell a little nudge. It is a real switch for screen readers, * and reduced motion keeps the changes but drops the swinging. * * Engine: gsap + @gsap/react for the bell (swing, clapper, waves and slash, * scoped with useGSAP), with motion/react for the pill's layout unfurl and * the label crossfade. */ gsap.registerPlugin(useGSAP); export interface BellToggleProps { /** Whether it is on. Leave out to let the toggle keep its own state. */ on?: boolean; /** Whether it starts on. Default `false`. */ defaultOn?: boolean; /** Called with the new state. */ onChange?: (on: boolean) => void; /** Label while off. Default `"Notify me"`. */ label?: string; /** Label while on. Default `"Notifications on"`. */ onLabel?: string; className?: string; } const GLIDE = [0.22, 1, 0.36, 1] as const; const UNFURL = { type: "spring", stiffness: 420, damping: 32 } as const; export const BellToggle = React.forwardRef( function BellToggle( { on: controlled, defaultOn = false, onChange, label = "Notify me", onLabel = "Notifications on", className, }, ref, ) { const still = !!useReducedMotion(); const [inner, setInner] = React.useState(defaultOn); const on = controlled ?? inner; const bell = React.useRef(null); const first = React.useRef(true); const { contextSafe } = useGSAP( () => { // Rest state for whichever side we start on. gsap.set("[data-slash]", { strokeDashoffset: on ? 1 : 0 }); gsap.set("[data-wave]", { strokeDashoffset: 1, opacity: 0 }); gsap.set("[data-body], [data-clapper]", { rotation: 0, svgOrigin: "12 4", }); }, { scope: bell }, ); const ring = contextSafe(() => { if (still) { gsap.set("[data-slash]", { strokeDashoffset: 1 }); return; } const tl = gsap.timeline(); tl.to("[data-slash]", { strokeDashoffset: 1, duration: 0.25, ease: "power2.in", }) // The bell swings on its hanger, each swing a little smaller. .to( "[data-body]", { keyframes: { rotation: [0, 22, -18, 13, -9, 5, -2, 0] }, duration: 0.9, ease: "sine.inOut", svgOrigin: "12 4", }, 0.08, ) // The clapper lags behind and swings the other way. .to( "[data-clapper]", { keyframes: { rotation: [0, -26, 22, -16, 10, -5, 0] }, duration: 0.95, ease: "sine.inOut", svgOrigin: "12 4", }, 0.14, ) // Sound waves draw out on both sides and fade. .fromTo( "[data-wave]", { strokeDashoffset: 1, opacity: 1, scale: 0.85, svgOrigin: "12 12" }, { strokeDashoffset: 0, scale: 1.1, duration: 0.35, ease: "power2.out", stagger: 0.06, }, 0.15, ) .to( "[data-wave]", { opacity: 0, duration: 0.35, ease: "power1.in" }, 0.6, ); }); const silence = contextSafe(() => { if (still) { gsap.set("[data-slash]", { strokeDashoffset: 0 }); return; } gsap.killTweensOf("[data-body], [data-clapper], [data-wave]"); gsap.set("[data-wave]", { opacity: 0 }); gsap .timeline() // A small droop as it goes quiet, then the slash strokes across. .to("[data-body], [data-clapper]", { keyframes: { rotation: [0, -8, 0] }, duration: 0.4, ease: "sine.inOut", svgOrigin: "12 4", }) .to( "[data-slash]", { strokeDashoffset: 0, duration: 0.3, ease: "power2.out" }, 0.05, ); }); const nudge = contextSafe(() => { if (!on || still) return; gsap.to("[data-body]", { keyframes: { rotation: [0, 9, -6, 0] }, duration: 0.5, ease: "sine.inOut", svgOrigin: "12 4", overwrite: true, }); }); // Ring or silence whenever the state changes, but not on first render. React.useEffect(() => { if (first.current) { first.current = false; return; } if (on) ring(); else silence(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [on]); const toggle = () => { const next = !on; if (controlled === undefined) setInner(next); onChange?.(next); }; return ( {/* Sound waves, two on each side. */} {/* The clapper hangs below the bell and swings on its own. */} {/* The slash that silences it. */} {on ? onLabel : label} ); }, );