Table Rows Populate
Placeholder cells resolve one column at a time, so the eye follows the fill instead of hunting for it.
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, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Table Rows Populate
*
* Placeholder cells resolve column by column rather than row by row.
* A table is read down its columns — you compare one field across
* customers, not one customer across fields — so filling it that way
* puts the motion in the same direction as the reading, and the eye
* follows the fill instead of scanning for what changed.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the table reads
* correctly on a light page and on a dark one.
* Works with zero props; pass `loaded` to drive it from your request
* state and `rows` / `columns` for your own data.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TableRowsPopulateProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your request state. Left undefined, the component
* fills itself after `revealAfterMs` so the file runs as-is. */
loaded?: boolean;
/** Only consulted while `loaded` is undefined. */
revealAfterMs?: number;
/** Column headers, left to right. */
columns?: string[];
/** Cell text, one array per row, matching `columns` in length. */
rows?: string[][];
/** CSS grid track sizes for the columns. */
columnWidths?: string;
/** Placeholder fill. A translucent neutral, so it reads in either theme. */
placeholderColor?: string;
/** Table width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds between one column resolving and the next. */
columnStep: number;
/** Seconds between rows inside a column — deliberately much smaller. */
rowStep: number;
fadeSeconds: number;
/** Entry travel for a resolving cell, in px. */
lift: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: cells hold numbers and names, so they travel two or
// three pixels at most and never scale — figures that grow into place
// stop reading as data. Variants change the pace of the column sweep;
// the settle is over-damped in all three.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a single cross-fade. For tables that refetch on every
// keystroke of a filter.
subtle: {
columnStep: 0.06,
rowStep: 0.012,
fadeSeconds: 0.15,
lift: 0,
spring: { type: "spring", stiffness: 770, damping: 57 },
},
// The all-purpose setting: the column sweep is clearly readable and
// the whole table has settled inside half a second.
default: {
columnStep: 0.11,
rowStep: 0.022,
fadeSeconds: 0.26,
lift: 3,
spring: { type: "spring", stiffness: 520, damping: 46 },
},
// A slower walk across the columns, for a wide report the user is
// waiting on.
playful: {
columnStep: 0.16,
rowStep: 0.03,
fadeSeconds: 0.34,
lift: 7,
spring: { type: "spring", stiffness: 350, damping: 36 },
},
};
const PLACEHOLDER_COLOR = "rgba(127, 127, 140, 0.2)";
/** Theme-adaptive neutral: mixing the inherited text color with
* transparent yields borders and header fills that are correctly toned
* in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_COLUMNS = ["Account", "Plan", "Seats", "MRR"];
const SAMPLE_ROWS = [
["Northwind Trading", "Scale", "128", "$4,280"],
["Halcyon Media", "Growth", "46", "$1,610"],
["Beacon Logistics", "Scale", "212", "$7,040"],
["Ardent Health", "Growth", "77", "$2,395"],
["Kestrel Labs", "Starter", "12", "$348"],
];
/** Placeholder widths per column, so the skeleton looks like the shape
* of the data rather than a wall of identical bars. */
const CELL_WIDTHS = ["82%", "58%", "42%", "62%"];
export default function TableRowsPopulate({
variant = "default",
loaded,
revealAfterMs = 800,
columns = SAMPLE_COLUMNS,
rows = SAMPLE_ROWS,
columnWidths = "1.45fr 0.85fr 0.5fr 0.75fr",
placeholderColor = PLACEHOLDER_COLOR,
width = 356,
}: TableRowsPopulateProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfLoaded, setSelfLoaded] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `loaded`, this timer stays out of the way.
useEffect(() => {
if (loaded !== undefined) return;
const timer = setTimeout(() => setSelfLoaded(true), revealAfterMs);
return () => clearTimeout(timer);
}, [loaded, revealAfterMs]);
const isLoaded = loaded ?? selfLoaded;
// Reduced motion keeps the column-by-column order, because that order
// is information about where to look. It drops the travel and halves
// the wait.
const columnStep = reduceMotion ? cfg.columnStep * 0.5 : cfg.columnStep;
const rowStep = reduceMotion ? cfg.rowStep * 0.5 : cfg.rowStep;
const lift = reduceMotion ? 0 : cfg.lift;
return (
<div
aria-busy={!isLoaded}
style={{
width,
borderRadius: 14,
border: `1px solid ${tone(12)}`,
background: tone(4),
overflow: "hidden",
}}
>
{/* Headers are never placeholders: the shape of the table is known
before the data is, and showing it costs nothing. */}
<div
style={{
display: "grid",
gridTemplateColumns: columnWidths,
gap: 12,
padding: "10px 14px",
background: tone(6),
borderBottom: `1px solid ${tone(10)}`,
fontSize: 10.5,
fontWeight: 600,
letterSpacing: 0.5,
opacity: 0.55,
}}
>
{columns.map((column, index) => (
<span
key={column}
style={{ textAlign: index >= 2 ? "right" : "left" }}
>
{column.toUpperCase()}
</span>
))}
</div>
<div>
{rows.map((row, rowIndex) => (
<div
key={row[0]}
style={{
display: "grid",
gridTemplateColumns: columnWidths,
gap: 12,
alignItems: "center",
padding: "11px 14px",
borderBottom:
rowIndex === rows.length - 1 ? "none" : `1px solid ${tone(7)}`,
}}
>
{row.map((cell, columnIndex) => {
// Column dominates the delay and row barely contributes:
// that ratio is what makes the fill read as a vertical
// wipe rather than a diagonal cascade.
const delay = columnIndex * columnStep + rowIndex * rowStep;
const alignRight = columnIndex >= 2;
return (
<div
key={columns[columnIndex] ?? columnIndex}
style={{ display: "grid", minWidth: 0 }}
>
{/* Placeholder and value share one grid cell, so the
row is already its final height before any data
lands and the swap cannot shift the table. */}
<motion.div
aria-hidden
initial={{ opacity: 1 }}
animate={{ opacity: isLoaded ? 0 : 1 }}
transition={{
duration: cfg.fadeSeconds * 0.7,
ease: "easeOut",
delay: isLoaded ? delay : 0,
}}
style={{
gridArea: "1 / 1",
justifySelf: alignRight ? "end" : "start",
width: CELL_WIDTHS[columnIndex] ?? "60%",
height: 9,
borderRadius: 5,
background: placeholderColor,
}}
/>
<motion.span
initial={{ opacity: 0, y: lift }}
animate={{
// Secondary columns rest below full strength, so
// the account name stays the anchor of the row.
opacity: isLoaded ? (columnIndex === 0 ? 1 : 0.68) : 0,
y: isLoaded ? 0 : lift,
}}
transition={{
opacity: {
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: isLoaded ? delay : 0,
},
y: { ...cfg.spring, delay: isLoaded ? delay : 0 },
}}
style={{
gridArea: "1 / 1",
textAlign: alignRight ? "right" : "left",
fontSize: 12.5,
fontWeight: columnIndex === 0 ? 500 : 400,
// Tabular figures keep the numeric columns from
// twitching sideways as they resolve.
fontVariantNumeric: "tabular-nums",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{cell}
</motion.span>
</div>
);
})}
</div>
))}
</div>
</div>
);
}About this pattern
Grids of figures are read down, not across — you compare one field between accounts, rarely one account across fields. So this fills the same way: the delay is dominated by the column index and barely touched by the row index, which turns the resolve into a vertical wipe moving left to right rather than a diagonal cascade. Headers are never placeholders, because the shape of the grid is known long before the data is. Each cell keeps its value and its placeholder in one grid cell so the row is already its final height, and the numeric columns use tabular figures so they cannot twitch sideways as they land.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Data table
Rows fill into an empty table as the query returns them.