"use client"; /** * GradientCard, from https://motion-brew.com/components/gradient-card * * One self contained file: the component and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. */ import * as React from "react"; import { type ClassValue, clsx } from "clsx"; import { Minus, Plus } from "lucide-react"; import { animate, motion, type MotionValue, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform } 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)); } // GradientCard /** * GradientCard * * Trigger: drag (plus press, hover, wheel, keys and in view). A metric card on * a live mesh gradient: soft colour blobs drift on their own under a fine * grain, and one of them leans toward the pointer. The colours follow the * value: bright and playful below the reference, warm orange at it, deep reds * above it. A glass chip shows the reference. The number is set in a dot * matrix, and when it changes each dot fades and swells on or off on a * spring, so digits morph into each other. At the bottom a frosted panel holds * a thermostat style dial: the rim of a wheel of ticks turns under a fixed * needle, with a round minus and plus button either side. Drag or flick it * (or scroll, or use the keys) and it turns with momentum, the ticks near the * needle grow, the number, caption and colours follow, then it springs onto * the nearest whole step. When it scrolls into view the card rises in and the * dial turns up to the value while the number counts with it. Reduced motion * keeps the gradient still and swaps glides for jumps. * * Engine: motion/react (a motion value for the dial position with inertia * and spring snaps, colour blends driven by it, looping keyframes for the * blobs, springs for every dot). */ export interface GradientCardProps { /** Label at the top. Default `"Body age"`. */ label?: string; /** Starting value. Default `25`. */ value?: number; /** Reference the caption compares against, like a calendar age. Default `27.5`. */ reference?: number; /** Lowest value on the dial. Default `18`. */ min?: number; /** Highest value on the dial. Default `90`. */ max?: number; /** Caption for the value; gets the gap to `reference` (negative is below). */ caption?: (value: number, gap: number) => string; /** * Gradient colours, four each (core, glow, accent, edge), for a value well * below the reference, at it, and well above it. The card blends between * them as the value moves. Default bright and playful when younger, warm * at the reference, deep reds when older. */ palette?: { young: Palette; even: Palette; old: Palette }; /** Drift speed of the gradient; `2` is twice as fast. Default `1`. */ speed?: number; /** Called when the value settles on a new step. */ onChange?: (value: number) => void; className?: string; } type Palette = [string, string, string, string]; const DEFAULT_PALETTE = { // Younger: bright and playful. young: ["#ff4fa3", "#ffc93c", "#8b5cf6", "#38d6c4"] as Palette, // Around the reference: warm orange. even: ["#f07a1f", "#f3a52b", "#e98a9a", "#b9c4d4"] as Palette, // Older: a deep mix of reds. old: ["#e3262f", "#ff5a36", "#9e1238", "#4a1621"] as Palette, }; const defaultCaption = (_: number, gap: number) => { const g = Math.abs(gap); const n = Number.isInteger(g) ? g.toFixed(0) : g.toFixed(1); if (g < 0.05) return "Right in step with your years"; return `${n} ${g === 1 ? "year" : "years"} ${gap < 0 ? "ahead of the clock" : "behind the clock"}`; }; // 5 by 7 dot glyphs for the digits, one string per row. const GLYPHS: Record = { "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"], "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"], "2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"], "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"], "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"], "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"], "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"], "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"], }; const DOT_SPRING = { type: "spring", stiffness: 520, damping: 30 } as const; const SNAP = { type: "spring", stiffness: 260, damping: 30 } as const; const GLIDE = [0.22, 1, 0.36, 1] as const; // The dial: ticks sit on a large wheel whose top edge shows through the panel. const RADIUS = 300; // px const STEP = 2.4; // degrees between ticks const UNIT = (RADIUS * STEP * Math.PI) / 180; // px of drag per step /** One digit as a 5 by 7 grid of dots; each dot springs on or off on its own. */ function DotDigit({ ch, still }: { ch: string; still: boolean }) { const rows = GLYPHS[ch] ?? GLYPHS["0"]!; return ( {rows.flatMap((row, r) => Array.from(row).map((bit, c) => { const on = bit === "1"; return ( ); }), )} ); } function Tick({ i, min, pos, }: { i: number; min: number; pos: MotionValue; }) { const v = min + i; const major = v % 5 === 0; // Ticks swell as they near the marker. const scaleY = useTransform(pos, (p) => { const d = v - p; return 1 + 0.8 * Math.exp(-(d * d) / 14); }); const opacity = useTransform(pos, (p) => { const d = Math.abs(v - p); return Math.max(0.3, 0.9 - d * 0.035); }); return ( // Spun to its place on the wheel; the tick hangs inward from the rim. {/* Every tenth step is numbered inside the rim. */} {v % 10 === 0 && ( {v} )} ); } /** A round frosted step button, in the library's round button style. */ function StepButton({ label, onPress, still, children, }: { label: string; onPress: () => void; still: boolean; children: React.ReactNode; }) { return ( {children} ); } export const GradientCard = React.forwardRef( function GradientCard( { label = "Body age", value: initial = 25, reference = 27.5, min = 18, max = 90, caption = defaultCaption, palette = DEFAULT_PALETTE, speed = 1, onChange, 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 clamp = (v: number) => Math.min(max, Math.max(min, v)); const target = clamp(Math.round(initial)); // The dial position, in value units. It starts low and runs up on the way in. const pos = useMotionValue(still ? target : clamp(target - 12)); const [shownValue, setShownValue] = React.useState(Math.round(pos.get())); useMotionValueEvent(pos, "change", (p) => { const r = Math.round(clamp(p)); setShownValue((cur) => (cur === r ? cur : r)); }); const settled = React.useRef(target); const settle = (v: number) => { if (settled.current !== v) { settled.current = v; onChange?.(v); } }; React.useEffect(() => { if (!shown || still) return; const c = animate(pos, target, { duration: 1.4, delay: 0.25, ease: GLIDE, }); return () => c.stop(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [shown]); const snapTo = (v: number, velocity = 0) => { const to = clamp(Math.round(v)); animate(pos, to, still ? { duration: 0 } : { ...SNAP, velocity }); settle(to); }; // Drag with momentum: follow the hand, then coast and land on a whole step. const drag = React.useRef<{ x: number; p: number; t: number; v: number; } | null>(null); const onDown = (e: React.PointerEvent) => { if (e.button !== 0) return; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); pos.stop(); drag.current = { x: e.clientX, p: pos.get(), t: performance.now(), v: 0 }; }; const onMove = (e: React.PointerEvent) => { const g = drag.current; if (!g) return; const now = performance.now(); let next = g.p - (e.clientX - g.x) / UNIT; // Rubber band past either end. if (next < min) next = min - (min - next) * 0.3; if (next > max) next = max + (next - max) * 0.3; const dt = Math.max(1, now - g.t); g.v = g.v * 0.6 + ((next - pos.get()) / dt) * 1000 * 0.4; g.t = now; pos.set(next); }; const onUp = () => { const g = drag.current; if (!g) return; drag.current = null; snapTo(pos.get() + Math.max(-12, Math.min(12, g.v * 0.22)), g.v); }; const wheelEnd = React.useRef(0); const onWheel = (e: React.WheelEvent) => { const d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; pos.stop(); pos.set(clamp(pos.get() + d / UNIT / 2)); window.clearTimeout(wheelEnd.current); wheelEnd.current = window.setTimeout(() => snapTo(pos.get()), 140); }; // The marker gives a tiny knock each time a whole step passes under it. const knock = useSpring(0, { stiffness: 900, damping: 18 }); const last = React.useRef(shownValue); React.useEffect(() => { if (last.current !== shownValue && !still) { knock.set(shownValue > last.current ? -2 : 2); window.setTimeout(() => knock.set(0), 40); } last.current = shownValue; }, [shownValue, knock, still]); // The dial turns so the current value sits under the marker. const dialRotate = useTransform(pos, (p) => -(p - min) * STEP); // The gradient blends from bright and playful (younger) through warm // orange (at the reference) to deep reds (older) as the dial turns. const stops = [reference - 10, reference, reference + 14]; const blend = (k: number) => [ palette.young[k]!, palette.even[k]!, palette.old[k]!, ]; const c0 = useTransform(pos, stops, blend(0)); const c1 = useTransform(pos, stops, blend(1)); const c2 = useTransform(pos, stops, blend(2)); const c3 = useTransform(pos, stops, blend(3)); // One blob leans toward the pointer. const px = useSpring(0, { stiffness: 60, damping: 18 }); const py = useSpring(0, { stiffness: 60, damping: 18 }); const onHover = (e: React.PointerEvent) => { if (still) return; const r = root.current!.getBoundingClientRect(); px.set(((e.clientX - r.left) / r.width - 0.5) * 120); py.set(((e.clientY - r.top) / r.height - 0.5) * 90); }; const gap = Math.round((shownValue - reference) * 10) / 10; const digits = String(shownValue).split(""); const k = 1 / Math.max(0.1, speed); return ( { px.set(0); py.set(0); }} initial={still ? false : { opacity: 0, y: 24, scale: 0.97 }} animate={shown ? { opacity: 1, y: 0, scale: 1 } : undefined} transition={{ duration: 0.7, ease: GLIDE }} className={cn( "relative isolate h-[300px] w-[400px] max-w-full overflow-hidden rounded-[28px] text-white shadow-[0_2px_6px_rgba(0,0,0,0.06),0_24px_48px_-24px_rgba(0,0,0,0.35)]", className, )} style={{ backgroundColor: c3 }} > {/* The live gradient: soft blobs drifting under one blur. It spills past the card so its soft edge never shows, and Safari on iPhone does not always clip a blurred, moving layer to rounded corners, so a clip-path holds it to the card's shape. */}
{/* A fine grain over the colour, and a soft light at the top. */}
\")", }} />

{label}

{/* A glass chip with the reference the caption compares against. */} Your years {reference}

{digits.map((d, i) => ( ))}

{caption(shownValue, gap)} {/* The dial, in a frosted panel with a round step button each side. */}
snapTo(settled.current - 1)} still={still} > {/* Drag, flick, scroll or use the arrow keys. */}
{ const by: Record = { ArrowLeft: -1, ArrowDown: -1, ArrowRight: 1, ArrowUp: 1, PageDown: -5, PageUp: 5, }; let to: number | null = null; if (e.key in by) to = settled.current + by[e.key]!; else if (e.key === "Home") to = min; else if (e.key === "End") to = max; if (to === null) return; e.preventDefault(); snapTo(to); }} className="relative h-full min-w-0 flex-1 cursor-grab touch-none select-none overflow-hidden rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-white/70 active:cursor-grabbing" style={{ maskImage: "linear-gradient(to right, transparent, black 26%, black 74%, transparent)", WebkitMaskImage: "linear-gradient(to right, transparent, black 26%, black 74%, transparent)", }} > {/* The wheel's centre sits far below; only its rim shows. */} {Array.from({ length: max - min + 1 }, (_, i) => ( ))} {/* The fixed marker: a glowing needle with a small cap. */}
snapTo(settled.current + 1)} still={still} >
); }, );