All patterns

Dashboard Tiles Cascade

Metric tiles resolve corner to corner in a diagonal wave instead of arriving as one slab.

loadingpremiumenergeticautomatic · finite · intermediate · ~0.9s
Variant

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.

285 lines · react + motion only
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.

Analytics dashboard loadKPI gridAdmin overviewReporting home

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    Overview tiles resolve in quick succession rather than switching over together.

Related patterns