"use client"; /** * GrowthStream, from https://motion-brew.com/components/growth-stream * * 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: arrow-up const ArrowUpIcon = (() => { interface ArrowUpIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArrowUpIconProps 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: { d: "m5 12 7-7 7 7", translateY: 0 }, animate: { d: "m5 12 7-7 7 7", translateY: [0, 3, 0], transition: { duration: 0.4, }, }, }; const SECOND_PATH_VARIANTS: Variants = { normal: { d: "M12 19V5" }, animate: { d: ["M12 19V5", "M12 19V10", "M12 19V5"], transition: { duration: 0.4, }, }, }; const ArrowUpIcon = 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} > ); } ); ArrowUpIcon.displayName = "ArrowUpIcon"; return ArrowUpIcon; })(); // 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; })(); // GrowthStream /** * GrowthStream * * Trigger: in view, then hover. A total built up over time, drawn as nested * bands that step wider before each checkpoint. When the card scrolls into view * the stream reveals left to right while the headline counts up on odometer * wheels, both from one motion value, and each checkpoint pill pops in exactly * as the reveal reaches it. Hovering or focusing a checkpoint rolls the headline * to the running total there, swaps the caption and hides the other pills. * Reduced motion shows the final state at once. * * Engine: motion/react (one motion value drives the clip-path reveal, the count * and the pill timing; odometer wheels; AnimatePresence for pills and captions). */ const GLIDE = [0.22, 1, 0.36, 1] as const; // The reveal and the count share this gentler ease-out so they finish together. const FILL = [0.5, 1, 0.89, 1] 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 and the lowest is turning, like a mechanical counter. Eleven cells (0 to 9, then 0) * let 9 roll smoothly into 0. */ function Wheel({ amount, rate, place, unit, }: { amount: MotionValue; rate: MotionValue; place: number; unit: number; }) { // Wheel position in digits (2.5 means halfway from 2 to 3). const position = useTransform(amount, (a) => { // Count in units of the lowest wheel to keep the math exact. const ticks = Math.max(0, a) / unit + 1e-6; const whole = Math.floor(ticks); const turning = ticks - whole; // how far the lowest wheel is into its next step const span = Math.round(place / unit); // lowest-wheel steps per step of this wheel const digit = Math.floor(whole / span) % 10; // Roll toward the next digit only while every wheel below reads 9 and the lowest is turning. const turn = span === 1 ? turning : whole % span === span - 1 ? turning : 0; return digit + turn; }); const y = useTransform(position, (p) => `${(-p * 100) / 11}%`); // Motion blur from how fast this wheel's digit changes: the amount's rate over its place value. // (Reading the wheel's own position would wrap at 9 to 0 and misjudge fast wheels.) 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} ))} ); } /** * The amount as an odometer that counts up through every value from zero, * driven by the same motion value as the arc. Whole-number wheels are added as * the number grows, so it starts at 0.00 rather than a row of zeros. */ function Odometer({ amount, format, label, }: { amount: MotionValue; format: Intl.NumberFormat; label: string; }) { const parts = React.useMemo(() => format.formatToParts(1234.5), [format]); const before = parts .slice( 0, parts.findIndex((p) => p.type === "integer"), ) .map((p) => p.value) .join(""); // End of the number is its last numeric part (whole numbers have no fraction part). const numeric = ["integer", "group", "decimal", "fraction"]; const lastNum = parts.reduce((last, p, i) => (numeric.includes(p.type) ? i : last), -1); const after = parts .slice(lastNum + 1) .map((p) => p.value) .join(""); const group = parts.find((p) => p.type === "group")?.value ?? ","; const decimal = parts.find((p) => p.type === "decimal")?.value ?? "."; const fractionDigits = parts.find((p) => p.type === "fraction")?.value.length ?? 0; const rate = useVelocity(amount); const wholeDigits = (a: number) => Math.max(1, String(Math.floor(Math.max(0, a) + 1e-6)).length); const [count, setCount] = React.useState(() => wholeDigits(amount.get())); useMotionValueEvent(amount, "change", (a) => { const next = wholeDigits(a); if (next !== count) setCount(next); }); const cells: React.ReactNode[] = []; for (let k = count - 1; k >= 0; k--) { cells.push( , ); if (k > 0 && k % 3 === 0) cells.push({group}); } if (fractionDigits > 0) cells.push({decimal}); for (let f = 1; f <= fractionDigits; f++) { cells.push(); } return ( {before} {cells} {after} ); } const POP = { initial: { opacity: 0, y: 6 }, animate: { opacity: 1, y: 0 }, exit: { opacity: 0, y: -4 }, transition: { duration: 0.25, ease: GLIDE }, } as const; // Chart geometry in SVG units. const W = 340; const H = 188; const MID = H / 2; const MIN_HALF = 4; // half-thickness at the very start const MAX_HALF = 76; // half-thickness at the final total const RAMP = 44; // width of each widening step // Each band's share of the full thickness, outer to inner. const BANDS = [ { scale: 1, alpha: 0.22 }, { scale: 0.68, alpha: 0.45 }, { scale: 0.36, alpha: 1 }, ] as const; const smooth = (t: number) => { const x = Math.min(1, Math.max(0, t)); return x * x * (3 - 2 * x); }; export interface StreamCheckpoint { /** Name shown in the caption on hover, e.g. "Week 2". */ label: string; /** Running total at this checkpoint. */ value: number; } export interface GrowthStreamProps { /** Running totals, left to right. The last one is the headline. Default three weeks. */ checkpoints?: StreamCheckpoint[]; /** Total for the previous period, for the change badge and caption. Default `31350`. */ previous?: number; /** Small label above the number. Default `"Income"`. */ title?: React.ReactNode; /** Name of the previous period in the caption. Default `"last month"`. */ previousLabel?: string; /** Name of the whole period in the hover caption. Default `"the month"`. */ periodLabel?: string; /** Band color. Default `"#8b5cf6"`. */ color?: string; /** Currency for the amounts (ISO code). Default `"USD"`. */ currency?: string; /** Locale for number formatting. Default `"en-US"`. */ locale?: string; /** Length of the reveal and count, in seconds. Default `1.6`. */ duration?: number; /** Called when the round header button is pressed. Leave out to hide it. */ onAction?: () => void; /** Accessible label for the round header button. Default `"View report"`. */ actionLabel?: string; className?: string; } const DEFAULT_CHECKPOINTS: StreamCheckpoint[] = [ { label: "Week 1", value: 8120 }, { label: "Week 2", value: 19480 }, { label: "Week 3", value: 32134 }, ]; export const GrowthStream = React.forwardRef(function GrowthStream( { checkpoints = DEFAULT_CHECKPOINTS, previous = 31350, title = "Income", previousLabel = "last month", periodLabel = "the month", color = "#8b5cf6", currency = "USD", locale = "en-US", duration = 1.6, onAction, actionLabel = "View report", className, }, forwardedRef, ) { const root = React.useRef(null); React.useImperativeHandle(forwardedRef, () => root.current as HTMLDivElement); const inView = useInView(root, { once: true, amount: 0.4 }); const reduceMotion = !!useReducedMotion(); const [active, setActive] = React.useState(null); const [reached, setReached] = React.useState(0); const money = React.useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency], ); const plain = React.useMemo(() => new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }), [locale]); const total = checkpoints[checkpoints.length - 1]?.value ?? 0; const change = previous > 0 ? ((total - previous) / previous) * 100 : 0; // Checkpoints sit evenly across the chart, away from the edges. const xs = checkpoints.map((_, i) => W * ((i + 0.6) / (checkpoints.length + 0.2))); // Each checkpoint owns the stretch where its growth happens: from the previous line up to its own, // and the last one runs on to the right edge. const segments = xs.map((x, i) => ({ from: i === 0 ? 0 : xs[i - 1]!, to: i === xs.length - 1 ? W : x })); // Half-thickness along x: starts thin and steps wider just before each checkpoint. const half = React.useCallback( (x: number, scale: number) => { let h = MIN_HALF; checkpoints.forEach((cp, i) => { const before = i === 0 ? 0 : checkpoints[i - 1]!.value; const grow = ((cp.value - before) / Math.max(1, total)) * (MAX_HALF - MIN_HALF); h += grow * smooth((x - (xs[i]! - RAMP - 8)) / RAMP); }); return h * scale; }, [checkpoints, total, xs], ); const bandPath = React.useCallback( (scale: number) => { const top: string[] = []; const bottom: string[] = []; for (let x = 0; x <= W; x += 4) { const h = Math.max(1.5, half(x, scale)); top.push(`${x} ${(MID - h).toFixed(2)}`); bottom.unshift(`${x} ${(MID + h).toFixed(2)}`); } return `M ${top.join(" L ")} L ${bottom.join(" L ")} Z`; }, [half], ); // `amount` drives the reveal and the count; `shown` is the headline, which follows a hovered checkpoint. const amount = useMotionValue(0); const shown = useMotionValue(0); const reveal = useTransform(amount, (a) => (total > 0 ? Math.min(1, a / total) : 1)); // Measured against the chart's view box, not each shape, so the blurred glow keeps its soft edges. // Only the right edge reveals; top and bottom sit well outside so the blur fades out freely. const clipPath = useTransform(reveal, (r) => `inset(-60% ${((1 - r) * 100).toFixed(2)}% -60% 0) view-box`); // Bands reveal in layers: the outer band leads and each inner one trails a little, // all reaching the right edge together as the count lands. const lagged = (r: number, lag: number) => { const t = Math.min(1, Math.max(0, (r - lag) / (1 - lag))); return `inset(-60% ${((1 - t) * 100).toFixed(2)}% -60% 0) view-box`; }; const outerClip = useTransform(reveal, (r) => lagged(r, 0)); const middleClip = useTransform(reveal, (r) => lagged(r, 0.08)); const coreClip = useTransform(reveal, (r) => lagged(r, 0.16)); const bandClips = [outerClip, middleClip, coreClip]; const glowId = `glow${React.useId().replace(/[^a-zA-Z0-9]/g, "")}`; useMotionValueEvent(amount, "change", (a) => { if (active === null) shown.set(a); // Pop each pill the moment the reveal passes its line. const r = total > 0 ? a / total : 1; const count = xs.filter((x) => x / W <= r + 1e-6).length; if (count !== reached) setReached(count); }); React.useEffect(() => { if (!inView) return; if (reduceMotion) { amount.set(total); shown.set(total); setReached(checkpoints.length); return; } const controls = animate(amount, total, { duration, ease: FILL }); return () => controls.stop(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [inView, total, duration, reduceMotion]); React.useEffect(() => { const target = active === null ? amount.get() : checkpoints[active]!.value; if (reduceMotion) { shown.set(target); return; } const controls = animate(shown, target, { duration: 0.6, ease: GLIDE }); return () => controls.stop(); }, [active, checkpoints, amount, shown, reduceMotion]); const caption = active !== null ? `By ${checkpoints[active]!.label} ยท ${Math.round((checkpoints[active]!.value / Math.max(1, total)) * 100)}% of ${periodLabel}` : `Compared to ${money.format(previous)} ${previousLabel}`; return (

{title}

{active === null && ( = 0 ? "text-emerald-600 dark:text-emerald-400" : "text-red-600 dark:text-red-400", )} > {Math.abs(change).toFixed(1)}% )}
{caption}
{onAction && ( )}
`${cp.label} ${money.format(cp.value)}`) .join(", ")}`} className="relative mt-6" onPointerLeave={() => setActive(null)} > {/* Soft glow: the outer band, a little larger and heavily blurred, at 15% opacity. */} {/* One copy of the bands per segment, each clipped to its stretch, so a hovered segment stays full while the rest dim, like the GoalGauge segments. */} {segments.map((seg, i) => ( ))} {segments.map((_, i) => ( {BANDS.map((band, k) => ( ))} ))} {xs.map((x, i) => ( ))} {/* Hover strips: the whole stretch of each segment is a target. */} {segments.map((seg, i) => ( setActive(i)} /> ))} {/* Checkpoint pills: focusable buttons on the lines for keyboard users. */} {checkpoints.map((cp, i) => ( ))}
); });