Chart No Data
The axes extend from the origin and a flat dashed baseline runs across, so an empty range still looks like a chart.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Chart No Data
*
* A panel with no numbers in it should still look like a chart. The axes
* extend from the origin, the gridlines fade in behind them, a flat
* dashed baseline runs left to right where the series would be, and only
* then does the message arrive. The reader learns the shape and the
* range before being told there is nothing in it — which is the
* difference between an empty chart and a broken one.
*
* 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.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ChartNoDataProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Headline over the empty plot. */
title?: string;
/** One line suggesting what to change. */
message?: string;
/** Tick labels, top to bottom on the value axis. */
valueLabels?: string[];
/** Tick labels, left to right on the time axis. */
timeLabels?: string[];
/** Height of the plot area in px. */
plotHeight?: number;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Seconds each axis takes to extend from the origin. */
axisSeconds: number;
/** Seconds the flat baseline takes to run across. */
baselineSeconds: number;
/** Seconds between gridlines. */
stagger: number;
fadeSeconds: number;
/** px the message travels on its way in. */
rise: number;
};
// Quality rule: no springs anywhere. Axes and baselines are measurements,
// and a measurement that overshoots its own end point and comes back is
// not one — every line here extends on an ease-out and stops exactly
// where the scale says it should.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For a small tile in a dashboard grid of many.
subtle: {
axisSeconds: 0.3,
baselineSeconds: 0.5,
stagger: 0.04,
fadeSeconds: 0.22,
rise: 4,
},
// The all-purpose setting: the frame builds, then the message lands.
default: {
axisSeconds: 0.42,
baselineSeconds: 0.72,
stagger: 0.06,
fadeSeconds: 0.28,
rise: 7,
},
// A slower build, for a report page where the chart is the subject.
playful: {
axisSeconds: 0.56,
baselineSeconds: 0.95,
stagger: 0.09,
fadeSeconds: 0.32,
rise: 10,
},
};
const VALUE_LABELS = ["100", "50", "0"];
const TIME_LABELS = ["Aug 1", "Aug 7", "Aug 14", "Aug 18"];
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps axes, gridlines and the baseline correct on light
* and dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function ChartNoData({
variant = "default",
title = "No data in this range",
message = "Nothing was recorded between these dates.",
valueLabels = VALUE_LABELS,
timeLabels = TIME_LABELS,
plotHeight = 116,
width = 320,
}: ChartNoDataProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const rise = reduceMotion ? 0 : cfg.rise;
const lineTransition = (delay: number) => ({
duration: reduceMotion ? 0.2 : cfg.axisSeconds,
ease: [0.16, 1, 0.3, 1] as const,
delay: reduceMotion ? 0 : delay,
});
return (
<div style={{ width, boxSizing: "border-box", padding: "16px 16px 12px" }}>
<div style={{ display: "flex", gap: 8 }}>
{/* Value ticks sit outside the plot so the axis can extend
without the labels moving with it. */}
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
height: plotHeight,
fontSize: 10,
opacity: 0.38,
fontVariantNumeric: "tabular-nums",
textAlign: "right",
minWidth: 22,
}}
>
{valueLabels.map((label, index) => (
<motion.span
key={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: 0.16 + index * cfg.stagger,
}}
>
{label}
</motion.span>
))}
</div>
<div
style={{
position: "relative",
flex: 1,
height: plotHeight,
minWidth: 0,
}}
>
{/* Gridlines behind everything, arriving top to bottom. */}
{[0, 0.5].map((fraction, index) => (
<motion.div
key={fraction}
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: 0.18 + index * cfg.stagger,
}}
style={{
position: "absolute",
left: 0,
right: 0,
top: `${fraction * 100}%`,
borderTop: `1px dashed ${tone(9)}`,
}}
/>
))}
{/* Value axis: extends upward from the origin. */}
<motion.div
aria-hidden
initial={{ scaleY: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
animate={{ scaleY: 1, opacity: 1 }}
transition={lineTransition(0)}
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 1,
background: tone(18),
transformOrigin: "center bottom",
}}
/>
{/* Time axis: extends to the right from the same corner. */}
<motion.div
aria-hidden
initial={{ scaleX: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
animate={{ scaleX: 1, opacity: 1 }}
transition={lineTransition(0.06)}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 1,
background: tone(18),
transformOrigin: "left center",
}}
/>
{/* The series that isn't there: a flat dashed run along zero.
Dashes stay crisp because the element scales rather than the
stroke being redrawn. */}
<motion.div
aria-hidden
initial={{ scaleX: reduceMotion ? 1 : 0, opacity: reduceMotion ? 0 : 1 }}
animate={{ scaleX: 1, opacity: 1 }}
transition={{
duration: reduceMotion ? 0.2 : cfg.baselineSeconds,
ease: [0.16, 1, 0.3, 1],
delay: reduceMotion ? 0 : 0.2,
}}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 2,
borderTop: `1.5px dashed ${tone(22)}`,
transformOrigin: "left center",
}}
/>
<motion.div
initial={{ opacity: 0, y: rise }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: 0.42,
}}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 4,
textAlign: "center",
paddingBottom: 10,
}}
>
<span style={{ fontSize: 13, fontWeight: 620 }}>{title}</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>{message}</span>
</motion.div>
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: 7,
marginLeft: 30,
fontSize: 10,
opacity: 0.38,
}}
>
{timeLabels.map((label, index) => (
<motion.span
key={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: cfg.fadeSeconds,
ease: "easeOut",
delay: 0.24 + index * cfg.stagger,
}}
>
{label}
</motion.span>
))}
</div>
</div>
);
}About this pattern
A blank rectangle where a chart should be reads as a failure; a chart with an honest zero in it reads as an answer. The value axis extends up from the origin, the time axis runs right from the same corner, gridlines and tick labels fade in behind them, and a flat dashed baseline crosses where the series would have been. Only then does the message arrive, so the reader takes in the shape and the range before being told the range is empty. Nothing springs — a measurement that overshoots its own end point and settles back is not a measurement.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Analytics view
A range with no sessions keeps its axes and scale rather than blanking the card.
Related patterns
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.
- Deleted Item TombstoneA removed row leaves a stub holding its slot open, with a hairline showing how long undo is on offer.
- Offline Cached OnlyWhen the network drops, downloaded rows stay crisp while the rest dim in a wave down the list.