"use client"; /** * DeviceTraffic, from https://motion-brew.com/components/device-traffic * * 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 { Tablet } from "lucide-react"; import { AnimatePresence, motion, type Transition, useAnimation, useInView, useReducedMotion, 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: arrow-down-right const ArrowDownRightIcon = (() => { interface ArrowDownRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowDownRightIconProps 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 HEAD_VARIANTS: Variants = { normal: { translateX: 0, translateY: 0 }, animate: { translateX: [0, -3, 0], translateY: [0, -3, 0], transition: { duration: 0.5, ease: "easeInOut", }, }, }; const SHAFT_VARIANTS: Variants = { normal: { translateX: 0, translateY: 0, scale: 1 }, animate: { translateX: [0, -3, 0], translateY: [0, -3, 0], scale: [1, 0.85, 1], originX: 1, originY: 1, transition: { duration: 0.5, ease: "easeInOut", }, }, }; const ArrowDownRightIcon = forwardRef< ArrowDownRightIconHandle, ArrowDownRightIconProps >(({ 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) controls.start("animate"); onMouseEnter?.(e); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("normal"); onMouseLeave?.(e); }, [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} > ); }); ArrowDownRightIcon.displayName = "ArrowDownRightIcon"; return ArrowDownRightIcon; })(); // Animated icon: arrow-up-right const ArrowUpRightIcon = (() => { interface ArrowUpRightIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowUpRightIconProps 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 ARROW_VARIANTS: Variants = { normal: { scale: 1, translateX: 0, translateY: 0, }, animate: { scale: [1, 0.85, 1], translateX: [0, -4, 0], translateY: [0, 4, 0], originX: 1, originY: 0, transition: { duration: 0.5, ease: "easeInOut", }, }, }; const ArrowUpRightIcon = forwardRef< ArrowUpRightIconHandle, ArrowUpRightIconProps >(({ 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) controls.start("animate"); onMouseEnter?.(e); }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (!isControlledRef.current) controls.start("normal"); onMouseLeave?.(e); }, [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} > ); }); ArrowUpRightIcon.displayName = "ArrowUpRightIcon"; return ArrowUpRightIcon; })(); // Animated icon: monitor-check const MonitorCheckIcon = (() => { interface MonitorCheckIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface MonitorCheckIconProps 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 CHECK_VARIANTS: Variants = { normal: { pathLength: 1, opacity: 1, transition: { duration: 0.3, }, }, animate: { pathLength: [0, 1], opacity: [0, 1], transition: { pathLength: { duration: 0.4, ease: "easeInOut" }, opacity: { duration: 0.4, ease: "easeInOut" }, }, }, }; const MonitorCheckIcon = forwardRef< MonitorCheckIconHandle, MonitorCheckIconProps >(({ 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} > ); }); MonitorCheckIcon.displayName = "MonitorCheckIcon"; return MonitorCheckIcon; })(); // Animated icon: radio const RadioIcon = (() => { interface RadioIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface RadioIconProps 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 VARIANTS: Variants = { normal: { opacity: 1, transition: { duration: 0.4, }, }, fadeOut: { opacity: 0, transition: { duration: 0.3 }, }, fadeIn: (i: number) => ({ opacity: 1, transition: { type: "spring", stiffness: 300, damping: 20, delay: i * 0.1, }, }), }; const RadioIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: async () => { await controls.start("fadeOut"); controls.start("fadeIn"); }, stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( async (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { await controls.start("fadeOut"); controls.start("fadeIn"); } }, [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} > ); } ); RadioIcon.displayName = "RadioIcon"; return RadioIcon; })(); // Animated icon: smartphone-nfc const SmartphoneNfcIcon = (() => { interface SmartphoneNfcIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface SmartphoneNfcIconProps 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, transition: { duration: 0.4, }, }, fadeOut: { opacity: 0, transition: { duration: 0.3 }, }, fadeIn: (i: number) => ({ opacity: 1, transition: { type: "spring", stiffness: 300, damping: 20, delay: i * 0.1, }, }), }; const SmartphoneNfcIcon = forwardRef< SmartphoneNfcIconHandle, SmartphoneNfcIconProps >(({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: async () => { await controls.start("fadeOut"); controls.start("fadeIn"); }, stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( async (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { await controls.start("fadeOut"); controls.start("fadeIn"); } }, [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} > ); }); SmartphoneNfcIcon.displayName = "SmartphoneNfcIcon"; return SmartphoneNfcIcon; })(); // DeviceTraffic /** * DeviceTraffic * * Trigger: in view, then hover, plus a live timeline. A white traffic card: * a light gray header with an icon tile, a title and a line of copy, then a * white panel overlapping it with the total visitors on rolling digits and a column per device * (desktop, tablet, mobile). Each column has a tall gauge that fills to the * device's share. When the card scrolls into view the gauges fill one after * another while the numbers roll up from zero. While live, new visits arrive * every few seconds: the total rolls on, a small "+n" floats up by the Live chip, the * shares drift and the gauges settle to their new levels on a spring. * Pointing at a column (or focusing it) dims the others, tilts its device * icon into play and swaps its change for the visitor count. Live updates * pause off screen and in a hidden tab. Reduced motion keeps the numbers * current without movement. * * Engine: motion/react (gauge fills sliding up on springs, spring digit columns, * AnimatePresence for the swaps and the "+n") with animated icons from * lucide-animated (MIT), vendored in src/icons. */ export type DeviceKind = "desktop" | "tablet" | "mobile"; export interface DeviceTrafficDevice { name: string; kind: DeviceKind; /** Share of visitors, in percent. Shares are scaled to add up to 100. */ share: number; /** Change against the last period, in percent. */ change: number; /** Gauge color. Defaults follow the card's palette. */ color?: string; } export interface DeviceTrafficProps { /** Title in the shell. Default `"Traffic by device"`. */ title?: React.ReactNode; /** Line under the title. */ description?: React.ReactNode; /** Label over the total. Default `"Total visitors"`. */ label?: string; /** Visitors at the start. Default `237456`. */ total?: number; /** Change of the total against the last period, in percent. Default `-1.4`. */ change?: number; devices?: DeviceTrafficDevice[]; /** New visits arrive every few seconds. Default true. */ live?: boolean; /** Seconds between live updates. Default `2.6`. */ interval?: number; /** Gauge colors, one per device, used when a device has no color of its own. */ colors?: string[]; /** Called by the Report button. Leave out to hide it. */ onReport?: () => void; /** Locale for numbers. Default `"en-US"`. */ locale?: string; /** Show the gray header band with the icon, title and copy. Default true; false leaves just the chart. */ header?: boolean; className?: string; } const PALETTE = ["#4f46e5", "#38bdf8", "#34d399"]; const GOOD = "#269a5a"; const BAD = "#d64545"; const GLIDE = [0.22, 1, 0.36, 1] as const; const ROLL: Transition = { type: "spring", stiffness: 200, damping: 26 }; const LEVEL: Transition = { type: "spring", stiffness: 120, damping: 18 }; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const DEFAULT_DEVICES: DeviceTrafficDevice[] = [ { name: "Desktop", kind: "desktop", share: 27, change: -3.2 }, { name: "Tablet", kind: "tablet", share: 12, change: -6.4 }, { name: "Mobile", kind: "mobile", share: 61, change: 0.8 }, ]; function Digit({ digit, still }: { digit: number; still: boolean }) { return ( {DIGITS.map((d) => ( {d} ))} ); } /** Text whose digits roll on their own columns, keyed from the right; other characters stay put. */ function RollText({ text, still }: { text: string; still: boolean }) { const chars = Array.from(text); return ( {chars.map((ch, i) => { const key = chars.length - i; return /\d/.test(ch) ? ( ) : ( {ch} ); })} ); } const zeroed = (text: string) => text.replace(/\d/g, "0"); /** Tablet has no animated icon upstream, so it turns a quarter like a device being rotated. */ function TabletIcon({ active, className, }: { active?: boolean; className?: string; }) { const still = !!useReducedMotion(); return ( ); } const ICONS: Record< DeviceKind, React.ComponentType<{ active?: boolean; className?: string }> > = { desktop: MonitorCheckIcon, tablet: TabletIcon, mobile: SmartphoneNfcIcon, }; function Change({ change, nudge, chip, }: { change: number; nudge: boolean; chip?: boolean; }) { const up = change >= 0; const color = up ? GOOD : BAD; const Arrow = up ? ArrowUpRightIcon : ArrowDownRightIcon; return ( {up ? "+" : "−"} {Math.abs(change).toFixed(1)}% ); } /** Shares scaled to 100. */ function normalize(shares: number[]) { const sum = shares.reduce((a, b) => a + b, 0) || 1; return shares.map((s) => (s / sum) * 100); } /** Rounded percentages that still add up to 100 (largest remainders get the spare points). */ function wholePercents(shares: number[]) { const floors = shares.map(Math.floor); let spare = 100 - floors.reduce((a, b) => a + b, 0); const order = shares .map((s, i) => ({ i, r: s - Math.floor(s) })) .sort((a, b) => b.r - a.r); for (const { i } of order) { if (spare <= 0) break; floors[i]!++; spare--; } return floors; } export const DeviceTraffic = React.forwardRef< HTMLDivElement, DeviceTrafficProps >(function DeviceTraffic( { title = "Traffic by device", description = "Watch where visitors arrive from, and spot shifts between screens as they happen.", label = "Total visitors", total: startTotal = 237456, change = -1.4, devices = DEFAULT_DEVICES, live = true, interval = 2.6, colors = PALETTE, onReport, locale = "en-US", header = true, className, }, ref, ) { const still = !!useReducedMotion(); const root = React.useRef(null); React.useImperativeHandle(ref, () => root.current!); const seen = useInView(root, { once: true, amount: 0.35 }); const visible = useInView(root, { amount: 0.1 }); const shown = seen || still; const format = React.useMemo(() => new Intl.NumberFormat(locale), [locale]); const [total, setTotal] = React.useState(startTotal); const [shares, setShares] = React.useState(() => normalize(devices.map((d) => d.share)), ); const [bump, setBump] = React.useState<{ n: number; key: number } | null>( null, ); const [hover, setHover] = React.useState(null); const [headHover, setHeadHover] = React.useState(false); // New numbers start the card over (compared by content, so inline arrays are fine). const shareKey = devices.map((d) => d.share).join(","); React.useEffect(() => { setTotal(startTotal); setShares(normalize(shareKey.split(",").map(Number))); }, [startTotal, shareKey]); // Live: a few visits every tick, and the shares drift a little. React.useEffect(() => { if (!live || !seen || !visible) return; const t = window.setInterval(() => { if (document.hidden) return; const n = 4 + Math.floor(Math.random() * 32); setTotal((v) => v + n); setBump((b) => ({ n, key: (b?.key ?? 0) + 1 })); setShares((s) => normalize(s.map((x) => Math.max(1, x + (Math.random() - 0.5) * 1.2))), ); }, interval * 1000); return () => window.clearInterval(t); }, [live, seen, visible, interval]); // The "+n" fades away on its own. React.useEffect(() => { if (!bump) return; const t = window.setTimeout(() => setBump(null), 1400); return () => window.clearTimeout(t); }, [bump]); const percents = wholePercents(shares); const totalText = format.format(total); return ( {/* Header band: icon, title and copy. Leave it out with header={false}. */} {header && (
setHeadHover(true)} onPointerLeave={() => setHeadHover(false)} >

{title}

{description}

)} {/* Headline */}
{label} {live && ( {!still && ( )} Live )} {/* New visits float up beside the Live chip and fade. */} {bump && !still && ( +{bump.n} )}
{totalText}
{onReport && ( Report )}
{/* One column per device */}
setHover(null)}> {devices.map((d, i) => { const Icon = ICONS[d.kind]; const color = d.color ?? colors[i % colors.length] ?? PALETTE[i % PALETTE.length]!; const on = hover === i; const dim = hover !== null && !on; const visitors = format.format( Math.round((total * (shares[i] ?? 0)) / 100), ); const pct = `${percents[i] ?? 0}%`; return ( = 0 ? "up" : "down"} ${Math.abs(d.change)}%`} onPointerEnter={() => setHover(i)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} animate={{ opacity: dim ? 0.45 : 1 }} transition={{ duration: 0.25, ease: GLIDE }} className="relative flex min-w-0 flex-col px-3 outline-none focus-visible:bg-[#242423]/[0.02]" > {i > 0 && ( )} {d.name} {/* The gauge: a tall track that fills to the device's share. */} {[25, 50, 75].map((g) => ( ))} {on ? ( {visitors} ) : ( )} ); })}
); });