Dashboard Tiles Cascade
Metric tiles resolve corner to corner in a diagonal wave instead of arriving as one slab.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Dashboard Tiles Cascade
*
* Six metric tiles resolve corner to corner instead of all at once. The
* delay is the diagonal band a tile sits in — row + column — so the grid
* fills the way an eye already reads it, and the last tile is only a
* third of a second behind the first.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the grid reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `tiles`, `loading`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type DashboardTile = {
label: string;
/** Already formatted — number formatting stays yours. */
value: string;
/** Movement against the previous period. */
delta: string;
/** true when the delta is an improvement. */
up: boolean;
/** 0–1 share of target, drawn as the rule under the value. */
share: number;
};
export type DashboardTilesCascadeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your query state. Left undefined, the grid resolves
* itself after `revealAfterMs` so the file runs as-is. */
loading?: boolean;
/** Only consulted while `loading` is undefined. */
revealAfterMs?: number;
/** Tiles, in reading order. */
tiles?: DashboardTile[];
/** Grid columns; the diagonal band is derived from this. */
columns?: number;
/** Overall width — px number or any CSS length. */
width?: number | string;
/** Accent for the rule under each value. */
accent?: string;
/** Fires once the last tile has landed. */
onRevealed?: () => void;
};
type VariantConfig = {
/** px a tile travels up as it lands. */
lift: number;
/** Seconds between one diagonal band and the next. */
band: number;
/** Crossfade from the waiting block to the tile. */
fadeSeconds: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Numbers have to be legible the moment they stop, so every damping ratio
// (ζ = damping / 2√stiffness) sits at or above 0.83 — one soft settle at
// most. Variants change travel and the width of the cascade, never the
// number of bounces.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00, 5px, near-simultaneous. For an executive grid of twelve
// tiles where a long cascade would read as slowness.
subtle: {
lift: 5,
band: 0.05,
fadeSeconds: 0.2,
spring: { type: "spring", stiffness: 480, damping: 44 },
},
// ζ ≈ 0.87. The all-purpose setting: the diagonal is legible without
// anyone waiting on it.
default: {
lift: 10,
band: 0.08,
fadeSeconds: 0.26,
spring: { type: "spring", stiffness: 380, damping: 34 },
},
// ζ ≈ 0.84, more travel and a wider band — for a landing dashboard
// that only loads once per session.
playful: {
lift: 16,
band: 0.11,
fadeSeconds: 0.3,
spring: { type: "spring", stiffness: 300, damping: 29 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ACCENT = "#7C7CF0";
const UP = "#10B981";
const DOWN = "#E0763C";
const SAMPLE_TILES: DashboardTile[] = [
{ label: "Active seats", value: "1,284", delta: "+6.2%", up: true, share: 0.78 },
{ label: "Trials", value: "212", delta: "+1.4%", up: true, share: 0.42 },
{ label: "Churn", value: "1.4%", delta: "-0.3pt", up: true, share: 0.18 },
{ label: "Tickets", value: "308", delta: "+11%", up: false, share: 0.63 },
{ label: "Response", value: "3m 42s", delta: "-18s", up: true, share: 0.35 },
{ label: "Uptime", value: "99.98%", delta: "flat", up: true, share: 0.99 },
];
export default function DashboardTilesCascade({
variant = "default",
loading,
revealAfterMs = 700,
tiles = SAMPLE_TILES,
columns = 3,
width = 342,
accent = ACCENT,
onRevealed,
}: DashboardTilesCascadeProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfResolved, setSelfResolved] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `loading`, this timer stays out of the way.
useEffect(() => {
if (loading !== undefined) return;
const timer = setTimeout(() => setSelfResolved(true), revealAfterMs);
return () => clearTimeout(timer);
}, [loading, revealAfterMs]);
const onRevealedRef = useRef(onRevealed);
useEffect(() => {
onRevealedRef.current = onRevealed;
}, [onRevealed]);
const isLoading = loading ?? !selfResolved;
// Reduced motion keeps the order — the grid still fills corner to
// corner, which is the information — and drops the travel, on a band
// short enough that the sequence never becomes a wait.
const band = reduceMotion ? cfg.band * 0.4 : cfg.band;
const lift = reduceMotion ? 0 : cfg.lift;
const lastBand = Math.floor((tiles.length - 1) / columns) + ((tiles.length - 1) % columns);
return (
<div
aria-busy={isLoading}
style={{
width,
display: "grid",
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: 8,
}}
>
{tiles.map((tile, index) => {
const row = Math.floor(index / columns);
const column = index % columns;
// The diagonal band, not the index: tiles on the same anti-diagonal
// land together, which is what makes the fill read as a wave
// crossing the grid rather than a queue draining.
const delay = isLoading ? 0 : (row + column) * band;
const isLast = row + column === lastBand;
return (
<div
key={tile.label}
style={{
display: "grid",
minHeight: 74,
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(10)}`,
}}
>
{/* Waiting block and tile share one grid cell, so the panel is
already the right size before any data lands and the fill
cannot shove the page around. */}
<motion.div
aria-hidden
initial={false}
animate={{ opacity: isLoading ? 1 : 0 }}
transition={{ duration: cfg.fadeSeconds, ease: "easeOut", delay }}
style={{
gridArea: "1 / 1",
display: "flex",
flexDirection: "column",
gap: 9,
padding: "12px 12px 0",
pointerEvents: "none",
}}
>
<span style={{ width: "58%", height: 8, borderRadius: 4, background: tone(11) }} />
<span style={{ width: "76%", height: 15, borderRadius: 5, background: tone(9) }} />
</motion.div>
<motion.div
aria-hidden={isLoading}
initial={{ opacity: 0, y: cfg.lift }}
animate={{ opacity: isLoading ? 0 : 1, y: isLoading ? lift : 0 }}
transition={{
opacity: { duration: cfg.fadeSeconds, ease: "easeOut", delay },
y: { ...cfg.spring, delay },
}}
onAnimationComplete={() => {
if (!isLoading && isLast) onRevealedRef.current?.();
}}
style={{
gridArea: "1 / 1",
display: "flex",
flexDirection: "column",
padding: "11px 12px 12px",
}}
>
<span
style={{
fontSize: 10.5,
fontWeight: 600,
letterSpacing: "0.05em",
textTransform: "uppercase",
opacity: 0.5,
whiteSpace: "nowrap",
}}
>
{tile.label}
</span>
<span
style={{
marginTop: 4,
fontSize: 17,
fontWeight: 650,
letterSpacing: -0.3,
lineHeight: 1.15,
fontVariantNumeric: "tabular-nums",
}}
>
{tile.value}
</span>
<span
style={{
marginTop: 3,
fontSize: 10.5,
fontWeight: 600,
color: tile.up ? UP : DOWN,
}}
>
{tile.delta}
</span>
{/* The rule is a scaleX off the left edge — a transform, so
it costs nothing per frame and cannot relayout the tile. */}
<span
aria-hidden
style={{
marginTop: 8,
height: 2,
borderRadius: 2,
background: tone(9),
overflow: "hidden",
}}
>
<motion.span
initial={{ scaleX: 0 }}
animate={{ scaleX: isLoading ? 0 : tile.share }}
transition={{
duration: reduceMotion ? 0.18 : 0.5,
ease: "easeOut",
delay: delay + (reduceMotion ? 0 : 0.08),
}}
style={{
display: "block",
height: "100%",
borderRadius: 2,
background: accent,
transformOrigin: "left center",
}}
/>
</span>
</motion.div>
</div>
);
})}
</div>
);
}About this pattern
The load state for an analytics grid, where six panels finish at once and the screen changes all at the same instant. Each tile's delay is the diagonal band it sits in — row plus column — so the grid fills the way the eye already reads it, top-left first, and the far corner is barely a third of a second behind. Waiting block and finished tile share a single grid cell, so the panel is the right size from the first frame and nothing shifts as the data lands. The tiles lift a few pixels on a spring that settles once; the numbers inside hold one size the whole way, because a value that scales is a value you have to re-read.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
Overview tiles resolve in quick succession rather than switching over together.
Related patterns
- Chart Bars GrowBars rise out of the baseline in reading order, with the axis landing first so there is something to measure against.
- Map Tiles LoadTiles fade in out of order over the placeholder grid, the way a real map delivers them.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.