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