All patterns

Sample Data Populate

Example figures sweep into an empty report so a new account can see what the product does before it has data of its own.

onboardingenergeticfriendlyinteraction · finite · starter · ~0.6s
Interactive · click to play
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.

339 lines · react + motion only
import { useState } from "react";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Sample Data Populate
 *
 * An empty report fills with example figures so a new account can see
 * what the product does before it has any data of its own. Placeholder
 * bars dissolve as real values rise into their place, row by row.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props; tune via `variant`, `rows`,
 * `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SampleRow = {
  source: string;
  sessions: string;
  change: string;
  /** Drives the color of the change cell. */
  down?: boolean;
};

export type SampleDataPopulateProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  title?: string;
  /** Your own example rows. The embedded sample is used when omitted. */
  rows?: SampleRow[];
  /** Label of the button that fills the table. */
  actionLabel?: string;
  /** Badge and button color. */
  accent?: string;
  /** Fires when the example data is put in. */
  onPopulate?: () => void;
};

type VariantConfig = {
  /** px each value rises into its cell. */
  rise: number;
  /** Seconds between one row and the next. */
  rowStagger: number;
  /** Seconds between the cells within a row. */
  cellStagger: number;
  /** Seconds a value takes to arrive. */
  valueSeconds: number;
  /** Seconds a placeholder bar takes to clear. */
  barSeconds: number;
};

// Quality rule: values are text, so they translate and fade only — no
// scale, no rebound, nothing that would make a figure hard to read
// while it lands. Variants change pace and travel.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost immediate. For a table that fills on every page view.
  subtle: {
    rise: 3,
    rowStagger: 0.035,
    cellStagger: 0.02,
    valueSeconds: 0.2,
    barSeconds: 0.12,
  },
  // A readable sweep down the table. All-purpose.
  default: {
    rise: 6,
    rowStagger: 0.06,
    cellStagger: 0.035,
    valueSeconds: 0.26,
    barSeconds: 0.16,
  },
  // A slower fill, for the first time an empty product is shown off.
  playful: {
    rise: 9,
    rowStagger: 0.09,
    cellStagger: 0.05,
    valueSeconds: 0.3,
    barSeconds: 0.2,
  },
};

const SAMPLE_ROWS: SampleRow[] = [
  { source: "Direct", sessions: "4,120", change: "+12%" },
  { source: "Search", sessions: "3,486", change: "+6%" },
  { source: "Referral", sessions: "1,905", change: "-3%", down: true },
  { source: "Social", sessions: "862", change: "+21%" },
];

const UP = "#2FA36B";
const DOWN = "#E0564D";
/** Placeholder bar widths, so the empty table does not read as a grid
 *  of identical blocks. */
const BAR_WIDTHS = ["68%", "54%", "76%", "46%"];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const COLUMNS = "1fr 64px 52px";

export default function SampleDataPopulate({
  variant = "default",
  title = "Traffic by source",
  rows = SAMPLE_ROWS,
  actionLabel = "Use sample data",
  accent = "#5B5BD6",
  onPopulate,
}: SampleDataPopulateProps) {
  const [filled, setFilled] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const rise = reduceMotion ? 0 : cfg.rise;
  const rowStagger = reduceMotion ? cfg.rowStagger * 0.5 : cfg.rowStagger;

  const populate = () => {
    setFilled(true);
    onPopulate?.();
  };

  /** Delay for the cell at (row, column) — down the table and across it,
   *  so the fill reads as a sweep rather than a flash. */
  const delayFor = (row: number, column: number) =>
    filled ? row * rowStagger + column * (reduceMotion ? 0 : cfg.cellStagger) : 0;

  return (
    <div
      style={{
        width: 320,
        boxSizing: "border-box",
        padding: 16,
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          marginBottom: 12,
        }}
      >
        <span style={{ fontSize: 13, fontWeight: 650 }}>{title}</span>
        <motion.span
          initial={false}
          animate={{ opacity: filled ? 1 : 0, y: filled ? 0 : -rise }}
          transition={{
            duration: 0.24,
            delay: filled ? rowStagger * rows.length + 0.1 : 0,
            ease: "easeOut",
          }}
          style={{
            padding: "2px 7px",
            fontSize: 10,
            fontWeight: 700,
            letterSpacing: 0.3,
            borderRadius: 6,
            color: accent,
            background: `color-mix(in srgb, ${accent} 14%, transparent)`,
          }}
        >
          SAMPLE
        </motion.span>
        <button
          type="button"
          onClick={filled ? () => setFilled(false) : populate}
          style={{
            marginLeft: "auto",
            padding: "6px 11px",
            fontSize: 11.5,
            fontWeight: 650,
            fontFamily: "inherit",
            color: filled ? "inherit" : "#ffffff",
            background: filled ? "transparent" : accent,
            border: filled ? `1px solid ${tone(16)}` : "none",
            borderRadius: 9,
            opacity: filled ? 0.7 : 1,
            cursor: "pointer",
          }}
        >
          {filled ? "Clear" : actionLabel}
        </button>
      </div>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: COLUMNS,
          gap: 10,
          padding: "0 2px 8px",
          fontSize: 10.5,
          fontWeight: 700,
          letterSpacing: 0.4,
          opacity: 0.4,
          borderBottom: `1px solid ${tone(10)}`,
        }}
      >
        <span>SOURCE</span>
        <span style={{ textAlign: "right" }}>SESSIONS</span>
        <span style={{ textAlign: "right" }}>CHANGE</span>
      </div>

      {rows.map((row, rowIndex) => (
        <div
          key={row.source}
          style={{
            display: "grid",
            gridTemplateColumns: COLUMNS,
            gap: 10,
            alignItems: "center",
            padding: "11px 2px",
            borderBottom:
              rowIndex === rows.length - 1 ? "none" : `1px solid ${tone(7)}`,
          }}
        >
          <Cell
            filled={filled}
            barWidth={BAR_WIDTHS[rowIndex % BAR_WIDTHS.length]}
            delay={delayFor(rowIndex, 0)}
            rise={rise}
            cfg={cfg}
            reduceMotion={Boolean(reduceMotion)}
          >
            <span style={{ fontSize: 12.5, fontWeight: 550 }}>{row.source}</span>
          </Cell>
          <Cell
            filled={filled}
            barWidth="72%"
            align="right"
            delay={delayFor(rowIndex, 1)}
            rise={rise}
            cfg={cfg}
            reduceMotion={Boolean(reduceMotion)}
          >
            <span style={{ fontSize: 12.5, fontVariantNumeric: "tabular-nums" }}>
              {row.sessions}
            </span>
          </Cell>
          <Cell
            filled={filled}
            barWidth="64%"
            align="right"
            delay={delayFor(rowIndex, 2)}
            rise={rise}
            cfg={cfg}
            reduceMotion={Boolean(reduceMotion)}
          >
            <span
              style={{
                fontSize: 12,
                fontWeight: 600,
                fontVariantNumeric: "tabular-nums",
                color: row.down ? DOWN : UP,
              }}
            >
              {row.change}
            </span>
          </Cell>
        </div>
      ))}
    </div>
  );
}

/** One cell holds both states at a fixed height, so the table never
 *  changes size as the placeholders are traded for values. */
function Cell({
  filled,
  barWidth,
  align = "left",
  delay,
  rise,
  cfg,
  reduceMotion,
  children,
}: {
  filled: boolean;
  barWidth: string;
  align?: "left" | "right";
  delay: number;
  rise: number;
  cfg: VariantConfig;
  reduceMotion: boolean;
  children: ReactNode;
}) {
  return (
    <span
      style={{
        position: "relative",
        display: "block",
        height: 17,
        lineHeight: "17px",
      }}
    >
      <motion.span
        aria-hidden
        initial={false}
        animate={{ opacity: filled ? 0 : 1 }}
        transition={{
          duration: reduceMotion ? 0.12 : cfg.barSeconds,
          delay: filled ? delay : 0,
          ease: "easeOut",
        }}
        style={{
          position: "absolute",
          top: 5,
          left: align === "right" ? undefined : 0,
          right: align === "right" ? 0 : undefined,
          width: barWidth,
          height: 8,
          borderRadius: 4,
          background: tone(11),
        }}
      />
      <motion.span
        initial={false}
        animate={{ opacity: filled ? 1 : 0, y: filled ? 0 : rise }}
        transition={{
          duration: reduceMotion ? 0.16 : cfg.valueSeconds,
          delay: filled ? delay + cfg.barSeconds * 0.5 : 0,
          ease: "easeOut",
        }}
        style={{
          position: "absolute",
          inset: 0,
          display: "block",
          textAlign: align,
        }}
      >
        {children}
      </motion.span>
    </span>
  );
}

About this pattern

Not a loading state — a deliberate offer to show the product working. Pressing the action clears each placeholder bar and rises the example value into its place, stepped down the table and across each row so the fill reads as a sweep rather than a flash. Every cell holds both states at a fixed height, so the table never changes size while it is being traded over, and a SAMPLE badge arrives last so nobody mistakes the figures for their own. Values are text: they translate and fade only, because a number that scales while it lands is a number that cannot be read.

Empty dashboardDemo modeProduct preview before setupExample report

Where it shows up

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

  • 10:15
    Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    A new grid arriving pre-filled with example records to edit over.

Related patterns