Display
CardStat
Stat card with a gradient number and animated counter
Preview
30Components
size
variant
number
label
animated
Code
import css from "./CardStat.module.css";
import { useEffect, useRef, useState } from "react";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface CardStatProps {
size: Size | ResponsiveSize;
variant: "outline" | "solid";
number: string | number;
label: string;
animated?: boolean;
}
const DURATION = 1500;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function CardStat({ size, variant, number, label, animated }: CardStatProps) {
const card = useRef<HTMLDivElement>(null);
const shown = useRef(0);
const [displayNumber, setDisplayNumber] = useState<string | number>(
animated && typeof number === "number" ? 0 : number,
);
const [started, setStarted] = useState(false);
const pending = animated && typeof number === "number" && !started;
useEffect(() => {
if (!animated || typeof number !== "number" || !card.current) {
shown.current = 0;
setDisplayNumber(number);
return;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
shown.current = number;
setDisplayNumber(number);
setStarted(true);
return;
}
const from = shown.current;
let raf = 0;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setStarted(true);
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min((now - start) / DURATION, 1);
shown.current =
progress < 1 ? from + (number - from) * easeOutCubic(progress) : number;
setDisplayNumber(progress < 1 ? Math.round(shown.current) : number);
if (progress < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
observer.disconnect();
},
{ threshold: 0.3 },
);
observer.observe(card.current);
return () => {
observer.disconnect();
cancelAnimationFrame(raf);
};
}, [animated, number]);
return (
<div ref={card} className={`${css.card} ${css[variant]} ${getSizeClasses(size)}`}>
<span className={`${css.card_number} ${pending ? css.pending : ""}`}>
{pending ? number : displayNumber}
</span>
<span className={css.card_label}>{label}</span>
</div>
);
}
.card {
width: 100%;
display: flex;
flex-direction: column;
/* Transparent on solid too: switching variant never moves the box. */
border: 1px solid transparent;
border-radius: var(--radius-12);
align-items: center;
text-align: center;
&.solid {
background: var(--color-surface);
}
&.outline {
position: relative;
--card-light: light-dark(#06b6d4, #2dd4bf);
background:
linear-gradient(90deg, transparent, var(--card-light), transparent)
top center / 70% 1px no-repeat,
linear-gradient(
to bottom,
color-mix(in srgb, var(--card-light) 6%, transparent),
transparent 40%
)
no-repeat;
/* border-image ignores border-radius, so the lit contour is a masked ring. */
&::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--card-light) 50%, transparent),
var(--color-border-card) 70%
);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
}
}
/* font-size is the label's, inherited; the number has a scale of its own. */
&.sm {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
&.sm_base {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_base {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_base {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_mobile {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_mobile {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_tablet {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_tablet {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_desktop {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_desktop {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_wide {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_wide {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
.card_number {
font-family: var(--font-heading);
font-size: var(--card-number-size);
font-weight: var(--weight-800);
line-height: 1;
background: linear-gradient(
to right,
var(--color-accent),
var(--color-accent-secondary)
);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
&.pending {
@media (scripting: enabled) {
opacity: 0;
}
}
}
.card_label {
color: var(--color-text);
font-weight: var(--weight-500);
}
}
<script setup lang="ts">
import { computed, ref, useTemplateRef, watchEffect } from "vue";
type Size = "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface CardStatProps {
size: Size | ResponsiveSize;
variant: "outline" | "solid";
number: string | number;
label: string;
animated?: boolean;
}
const DURATION = 1500;
const easeOutCubic = (progress: number) => 1 - Math.pow(1 - progress, 3);
const { size, number, animated } = defineProps<CardStatProps>();
const card = useTemplateRef<HTMLDivElement>("card");
const displayNumber = ref<string | number>(
animated && typeof number === "number" ? 0 : number,
);
const started = ref(false);
let shown = 0;
const pending = computed(() => animated && typeof number === "number" && !started.value);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
// flush "post" so the card element is already there when the effect runs.
watchEffect(
(onCleanup) => {
if (!animated || typeof number !== "number" || !card.value) {
shown = 0;
displayNumber.value = number;
return;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) {
shown = number;
displayNumber.value = number;
started.value = true;
return;
}
const from = shown;
let raf = 0;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
started.value = true;
const start = performance.now();
const tick = (now: number) => {
const progress = Math.min((now - start) / DURATION, 1);
shown = progress < 1 ? from + (number - from) * easeOutCubic(progress) : number;
displayNumber.value = progress < 1 ? Math.round(shown) : number;
if (progress < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
observer.disconnect();
},
{ threshold: 0.3 },
);
observer.observe(card.value);
onCleanup(() => {
observer.disconnect();
cancelAnimationFrame(raf);
});
},
{ flush: "post" },
);
</script>
<template>
<div ref="card" class="card" :class="[variant, sizeClasses]">
<span class="card_number" :class="{ pending }">{{ pending ? number : displayNumber }}</span>
<span class="card_label">{{ label }}</span>
</div>
</template>
<style scoped>
.card {
width: 100%;
display: flex;
flex-direction: column;
/* Transparent on solid too: switching variant never moves the box. */
border: 1px solid transparent;
border-radius: var(--radius-12);
align-items: center;
text-align: center;
&.solid {
background: var(--color-surface);
}
&.outline {
position: relative;
--card-light: light-dark(#06b6d4, #2dd4bf);
background:
linear-gradient(90deg, transparent, var(--card-light), transparent)
top center / 70% 1px no-repeat,
linear-gradient(
to bottom,
color-mix(in srgb, var(--card-light) 6%, transparent),
transparent 40%
)
no-repeat;
/* border-image ignores border-radius, so the lit contour is a masked ring. */
&::before {
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
padding: 1px;
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--card-light) 50%, transparent),
var(--color-border-card) 70%
);
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;
pointer-events: none;
}
}
/* font-size is the label's, inherited; the number has a scale of its own. */
&.sm {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
&.sm_base {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_base {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_base {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.sm_mobile {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_mobile {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_mobile {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.sm_tablet {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_tablet {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_tablet {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.sm_desktop {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_desktop {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_desktop {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.sm_wide {
padding: var(--space-24) var(--space-20);
gap: var(--space-8);
--card-number-size: var(--text-32);
font-size: var(--text-12);
}
&.md_wide {
padding: var(--space-32) var(--space-24);
gap: var(--space-12);
--card-number-size: var(--text-48);
font-size: var(--text-14);
}
&.lg_wide {
padding: var(--space-40) var(--space-32);
gap: var(--space-16);
--card-number-size: var(--text-64);
font-size: var(--text-16);
}
}
.card_number {
font-family: var(--font-heading);
font-size: var(--card-number-size);
font-weight: var(--weight-800);
line-height: 1;
background: linear-gradient(
to right,
var(--color-accent),
var(--color-accent-secondary)
);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
&.pending {
@media (scripting: enabled) {
opacity: 0;
}
}
}
.card_label {
color: var(--color-text);
font-weight: var(--weight-500);
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | ResponsiveSize | required | Card size (number, label and padding). Fixed or responsive. |
| variant | "outline" | "solid" | required | Visual style: an outline lit from the top, or a solid fill. |
| number | string | number | required | Stat value. As a number it can animate; as a string ("+30") it renders as-is. |
| label | string | required | Text under the number. |
| animated | boolean | — | Counts from 0 to the value on entering the viewport, then from the current figure to each new one (numbers only; honors reduced-motion). |