"use client"; /** * QuotaGauge, from https://motion-brew.com/components/quota-gauge * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { animate, AnimatePresence, motion, type MotionValue, useAnimation, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, useVelocity, type Variants } 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)); } // Animated icon: zap const ZapIcon = (() => { interface ZapHandle { startAnimation: () => void; stopAnimation: () => void; } interface ZapProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, }; const ZapIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ZapIcon.displayName = "ZapIcon"; return ZapIcon; })(); // QuotaGauge /** * QuotaGauge * * Trigger: in view, then hover. A white card for a plan's credit quota: a * three quarter gauge made of radial ticks, like a speedometer, around a * bolt. When it scrolls into view the ticks light up one by one up to the * amount used, the lit ones growing a little longer, while the credits count * up on odometer wheels beneath the bolt and the share of the plan rolls in. * The tick at the tip glows. Hovering the card plays the bolt and swaps the * caption to what is left. Reduced motion shows the final state at once. * * Engine: motion/react (a counting motion value drives the ticks and the * numbers, springs for the ticks, AnimatePresence for the caption) with an * animated bolt from lucide-animated (MIT), vendored in src/icons. */ export interface QuotaGaugeProps { /** Credits used so far. Default `2022`. */ value?: number; /** The plan limit. Default `2500`. */ goal?: number; /** Unit after the amount. Default `"credits used"`. */ unit?: string; /** Ticks on the gauge. Default `44`. */ ticks?: number; /** Colour of lit ticks. Default ink. */ color?: string; className?: string; } const GLIDE = [0.22, 1, 0.36, 1] as const; // Number rolls, the same as GoalGauge and SpendingMeter: one 0 to 9 column per digit. const ROLL = { type: "spring", stiffness: 280, damping: 26, mass: 0.8, } as const; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const; // Cap for the odometer's motion blur, in px. const MAX_BLUR = 0.5; /** * One odometer wheel for a decimal place. The lowest wheel turns continuously; * every higher wheel holds still and only ticks over while all the wheels below * it read 9, like a mechanical counter. Eleven cells (0 to 9, then 0) let 9 * roll smoothly into 0. */ function Wheel({ value, rate, place, }: { value: MotionValue; rate: MotionValue; place: number; }) { const position = useTransform(value, (a) => { const ticks = Math.max(0, a) + 1e-6; const whole = Math.floor(ticks); const turning = ticks - whole; const digit = Math.floor(whole / place) % 10; const turn = place === 1 ? turning : whole % place === place - 1 ? turning : 0; return digit + turn; }); const y = useTransform(position, (p) => `${(-p * 100) / 11}%`); // Motion blur from how fast this wheel's digit changes. const filter = useTransform(rate, (v) => { const px = Math.min(MAX_BLUR, (Math.abs(v) / place) * 0.015); return px < 0.1 ? "none" : `blur(${px.toFixed(2)}px)`; }); return ( {[...DIGITS, 0].map((d, i) => ( {d} ))} ); } /** * A whole number as an odometer that counts through every value, driven by a * motion value. Wheels are added as the number grows, with a separator every * three places. */ function Odometer({ value, label, group = ",", }: { value: MotionValue; label: string; group?: string; }) { const rate = useVelocity(value); const places = (a: number) => Math.max(1, String(Math.floor(Math.max(0, a) + 1e-6)).length); const [count, setCount] = React.useState(() => places(value.get())); useMotionValueEvent(value, "change", (a) => { const n = places(a); if (n !== count) setCount(n); }); const cells: React.ReactNode[] = []; for (let k = count - 1; k >= 0; k--) { cells.push( , ); if (k > 0 && k % 3 === 0 && group) cells.push({group}); } return ( {/* An invisible digit gives the row a text baseline, so it lines up with words beside it. */} 0 {cells} ); } const SWEEP = 270; // degrees, open at the bottom const SIZE = 240; const C = SIZE / 2; const R_OUT = 108; const R_IN = 94; export const QuotaGauge = React.forwardRef( function QuotaGauge( { value = 2022, goal = 2500, unit = "credits used", ticks = 44, color = "#242423", className, }, ref, ) { const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current!); const still = !!useReducedMotion(); const inView = useInView(root, { once: true, amount: 0.4 }); const shown = inView || still; const [hover, setHover] = React.useState(false); const share = Math.min(1, value / Math.max(1, goal)); const amount = useMotionValue(still ? value : 0); const [lit, setLit] = React.useState(still ? Math.round(share * ticks) : 0); useMotionValueEvent(amount, "change", (v) => setLit(Math.round((Math.min(v, goal) / goal) * ticks)), ); React.useEffect(() => { if (!shown || still) return; const c = animate(amount, value, { duration: 1.6, delay: 0.25, ease: GLIDE, }); return () => c.stop(); }, [shown, still, value, amount]); const left = Math.max(0, goal - value); const start = 90 + (360 - SWEEP) / 2; // degrees, from the bottom left return ( setHover(true)} onPointerLeave={() => setHover(false)} initial={still ? false : { opacity: 0, y: 16 }} animate={shown ? { opacity: 1, y: 0 } : undefined} transition={{ duration: 0.6, ease: GLIDE }} className={cn( "w-[320px] max-w-full rounded-3xl bg-white p-6 text-[#242423] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_32px_-16px_rgba(0,0,0,0.18)] dark:bg-zinc-900 dark:text-zinc-100", className, )} >
{Array.from({ length: ticks }, (_, i) => { const a = ((start + (i / (ticks - 1)) * SWEEP) * Math.PI) / 180; const on = i < lit; const tip = i === lit - 1; const long = on ? 8 : 0; return ( ); })} {/* Centre: the bolt, the amount and the unit. */}

{unit}

{/* Caption: progress, or what is left on hover. */}
{hover ? ( <>

{left > 0 ? `${left.toLocaleString()} left` : "Limit reached"}

{left === 0 ? "Upgrade for more" : share >= 0.75 ? "Close to your limit" : "Plenty of room"}

) : ( <>

{Math.round(share * 100)}% of your plan

Monthly limit {goal.toLocaleString()}

)}
); }, );