The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { IconArrowDownRight, IconArrowUpRight } from "@tabler/icons-react"; |
| 6 | import { cn } from "../lib/utils"; |
| 7 | import { Sparkline } from "./chart"; |
| 8 |
| 9 | /** Counts up to a number once it scrolls into view. */ |
| 10 | export function AnimatedNumber({ |
| 11 | value, |
| 12 | format = (next) => Math.round(next).toLocaleString(), |
| 13 | className, |
| 14 | }: { |
| 15 | value: number; |
| 16 | format?: (value: number) => string; |
| 17 | className?: string; |
| 18 | }) { |
| 19 | const ref = React.useRef<HTMLSpanElement | null>(null); |
| 20 | const inView = useInView(ref, { once: true }); |
| 21 | const reduceMotion = useReducedMotion(); |
| 22 | const motionValue = useMotionValue(reduceMotion ? value : 0); |
| 23 | const display = useTransform(motionValue, (latest) => format(latest)); |
| 24 | React.useEffect(() => { |
| 25 | if (!inView) return; |
| 26 | if (reduceMotion) { |
| 27 | motionValue.set(value); |
| 28 | return; |
| 29 | } |
| 30 | const controls = animate(motionValue, value, { duration: 0.8, ease: [0.22, 1, 0.36, 1] }); |
| 31 | return () => controls.stop(); |
| 32 | }, [inView, motionValue, reduceMotion, value]); |
| 33 | return ( |
| 34 | <motion.span ref={ref} className={cn("tabular-nums", className)}> |
| 35 | {display} |
| 36 | </motion.span> |
| 37 | ); |
| 38 | } |
| 39 |
| 40 | export interface StatProps { |
| 41 | label: string; |
| 42 | value: React.ReactNode; |
| 43 | delta?: { value: string; tone?: "positive" | "negative" | "neutral" }; |
| 44 | hint?: React.ReactNode; |
| 45 | trend?: number[]; |
| 46 | className?: string; |
| 47 | } |
| 48 |
| 49 | /** One metric: label, value, change, and an optional trend line. */ |
| 50 | export function Stat({ label, value, delta, hint, trend, className }: StatProps) { |
| 51 | const tone = delta?.tone ?? "neutral"; |
| 52 | return ( |
| 53 | <div |
| 54 | className={cn( |
| 55 | "flex min-w-0 flex-col gap-1.5 rounded-2xl border border-border/60 bg-card p-4 shadow-[0_1px_2px_rgba(0,0,0,0.03)]", |
| 56 | className, |
| 57 | )} |
| 58 | > |
| 59 | <p className="truncate text-xs font-medium text-muted-foreground">{label}</p> |
| 60 | <div className="flex items-end justify-between gap-3"> |
| 61 | <div className="min-w-0 truncate text-2xl font-semibold tracking-tight text-foreground tabular-nums">{value}</div> |
| 62 | {trend && trend.length > 1 ? ( |
| 63 | <Sparkline values={trend} tone={tone === "negative" ? "danger" : "positive"} className="w-24 shrink-0" /> |
| 64 | ) : null} |
| 65 | </div> |
| 66 | {delta || hint ? ( |
| 67 | <div className="flex items-center gap-2 text-xs"> |
| 68 | {delta ? ( |
| 69 | <span |
| 70 | className={cn( |
| 71 | "inline-flex items-center gap-0.5 rounded-md px-1.5 py-px font-medium tabular-nums", |
| 72 | tone === "positive" && "bg-positive/12 text-positive-fg", |
| 73 | tone === "negative" && "bg-risk/12 text-risk-fg", |
| 74 | tone === "neutral" && "bg-muted text-muted-foreground", |
| 75 | )} |
| 76 | > |
| 77 | {tone === "positive" ? <IconArrowUpRight className="size-3" /> : null} |
| 78 | {tone === "negative" ? <IconArrowDownRight className="size-3" /> : null} |
| 79 | {delta.value} |
| 80 | </span> |
| 81 | ) : null} |
| 82 | {hint ? <span className="truncate text-muted-foreground">{hint}</span> : null} |
| 83 | </div> |
| 84 | ) : null} |
| 85 | </div> |
| 86 | ); |
| 87 | } |
| 88 |
| 89 | /** A responsive row of stats. */ |
| 90 | export function StatGrid({ children, className }: { children: React.ReactNode; className?: string }) { |
| 91 | return <div className={cn("grid grid-cols-[repeat(auto-fit,minmax(11rem,1fr))] gap-3", className)}>{children}</div>; |
| 92 | } |
| 93 |