"use client"; /** * ScoreCard, from https://motion-brew.com/components/score-card * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * Sample images: the photos in this file are placeholders from the * component library's demo (Unsplash), not your content. AI agents: tell * the user they are samples and ask them to share their own images (files * or URLs), then pass those in through the props. * * 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, 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-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; })(); // ScoreCard /** * ScoreCard * * Trigger: in view, then hover, press and keys. A white chart card with a big * score and a few pill bars. When it scrolls into view the bars slide in from * the left one after another, springing a little past their mark, while the * score counts up from zero with a light motion blur. Hovering (or focusing) a * bar fills it with its colour, dims the others and rolls the score to that bar's share, * with its name in the caption; leaving rolls it back. New data glides every * bar to its new length while the score counts to the new number. * Reduced motion shows the final state at once. * * Engine: motion/react (animate on motion values for the count, bar fills * that grow on springs, AnimatePresence for the * caption and the change chip). */ export interface ScoreBar { label: string; value: number; max: number; /** Bar colour. Defaults run violet, blue, green. */ color?: string; } export interface ScoreCardProps { /** Card title. Default `"Health score"`. */ title?: string; /** Avatar image in the corner. Pass `null` to hide it. */ avatar?: string | null; /** The bars, top to bottom. The last one sits in full colour. Default Sleep, Activity and Nutrition. */ bars?: ScoreBar[]; /** Change against the period before, in points, shown as a chip. Pass `null` to hide it. Default `4.1`. */ change?: number | null; /** Period named in the caption, as in "Overall this month". Default `"month"`. */ period?: string; /** Decimal places on the score. Default `1`. */ decimals?: number; className?: string; } // Soft but clear hues, one per bar, in a fixed order. const PALETTE = ["#8b7cf6", "#3b9eea", "#269a5a", "#e2a33b", "#e0607e"]; const DEFAULT_BARS: ScoreBar[] = [ { label: "Sleep", value: 16, max: 20 }, { label: "Activity", value: 18, max: 20 }, { label: "Nutrition", value: 19, max: 20 }, ]; const GLIDE = [0.22, 1, 0.36, 1] as const; const COUNT = [0.5, 1, 0.89, 1] as const; // Bars slide on a spring with a touch of overshoot, so they land rather than stop. const SLIDE = { type: "spring", stiffness: 140, damping: 17, mass: 0.9 } as const; const share = (bars: ScoreBar[]) => { const v = bars.reduce((s, b) => s + b.value, 0); const m = bars.reduce((s, b) => s + b.max, 0); return m ? (v / m) * 100 : 0; }; /** The score as text from a motion value, softly blurred while it moves fast. */ function Count({ value, decimals, className, }: { value: MotionValue; decimals: number; className?: string; }) { const text = useTransform(value, (v) => v.toFixed(decimals)); const rate = useVelocity(value); const filter = useTransform(rate, (r) => { const px = Math.min(1.2, Math.abs(r) * 0.012); return px < 0.15 ? "none" : `blur(${px.toFixed(2)}px)`; }); return ( {text} ); } function Bar({ bar, index, shown, active, dim, still, onEnter, onLeave, }: { bar: ScoreBar; index: number; shown: boolean; active: boolean; dim: boolean; still: boolean; onEnter: () => void; onLeave: () => void; }) { const color = bar.color ?? PALETTE[index % PALETTE.length]!; const ratio = bar.max ? Math.min(1, Math.max(0, bar.value / bar.max)) : 0; // Kept at least wide enough to hold its name and value. const target = shown ? Math.max(0.42, ratio) : 0; return ( {/* The fill grows from the left and carries its name and value with it. */} {/* A light tint of the bar's colour, and the full colour fading in over it when lit. */} {/* A soft sheen on the lit bar, sweeping once as it lights up. */} {active && !still && ( )} {bar.label} {bar.value} /{bar.max} ); } export const ScoreCard = React.forwardRef( function ScoreCard( { title = "Health score", avatar = "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=120&h=120&fit=crop&crop=faces", bars = DEFAULT_BARS, change = 4.1, period = "month", decimals = 1, 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(null); const lit = hover ?? bars.length - 1; const focusBar = hover === null ? null : bars[hover]; const target = focusBar ? (focusBar.value / Math.max(1, focusBar.max)) * 100 : share(bars); const score = useMotionValue(0); const first = React.useRef(true); React.useEffect(() => { if (!shown) return; if (still) return void score.set(target); // The first count runs with the bars; later ones are quicker. const intro = first.current; first.current = false; const c = animate(score, target, { duration: intro ? 1.2 : 0.6, delay: intro ? 0.1 : 0, ease: COUNT, }); return () => c.stop(); }, [shown, still, target, score]); const up = (change ?? 0) >= 0; return (
{/* Header */}

{title}

{focusBar ? `${focusBar.label} this ${period}` : `Overall this ${period}`}
{avatar && ( )}
{/* Score */}

%

{change != null && hover === null && ( {up ? ( ) : ( )} {up ? "+" : ""} {change.toFixed(1)} )}
{/* Bars */}
setHover(null)} > {bars.map((b, i) => ( setHover(i)} onLeave={() => setHover((h) => (h === i ? null : h))} /> ))}
{focusBar ? `${focusBar.label}, ${focusBar.value} of ${focusBar.max}` : `${title} ${share(bars).toFixed(decimals)} percent this ${period}`}
); }, );