Personal Record
The newest column overtakes the best-ever marker, which rides up on it while the beaten mark drops away as a reference.
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 {
animate,
motion,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
useTransform,
} from "motion/react";
/**
* Vibary · Personal Record
*
* The moment a new best goes past the old one. The latest column rises,
* and the best-ever marker rides on top of it the instant it is
* overtaken — the marker is pushed up by the column rather than moved by
* a timer, so the line and the figure can never disagree with the data.
*
* What the old number does matters as much as what the new one does. It
* does not vanish; it drops a couple of pixels, dims, and stays as a
* dashed reference, because a record only means something next to the
* thing it beat.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The earlier columns are mixed from the inherited text color; the
* record colour is semantic and stays literal.
* Works with zero props; tune via `variant`, `sessions`, `previousBest`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RecordSession = {
/** Short axis label. */
label: string;
/** The measurement for that session. */
value: number;
};
export type RecordBrokenHighlightProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Small label above the title. */
eyebrow?: string;
/** What is being measured. */
title?: string;
/** Earlier sessions plus the newest one, oldest first. */
sessions?: RecordSession[];
/** The mark being beaten. */
previousBest?: number;
/** Suffix on both figures, e.g. "km". */
unit?: string;
/** Wording of the pill that appears once the mark is passed. */
recordLabel?: string;
/** Record colour. Semantic, so it stays literal. */
accent?: string;
/** Fires at the frame the new value passes the old mark. */
onRecord?: () => void;
};
type VariantConfig = {
/** Beat before the newest column starts rising. */
delay: number;
/** How long the column takes to reach its value. */
rise: number;
/** How far the retired mark drops as it becomes a reference, in px. */
drop: number;
};
// Nothing springs. A measurement that overshoots its own value is a
// number the person did not earn, so the column is a decelerating tween
// that stops exactly where the data stops and the marker simply rides
// it. Variants change pace and the size of the retirement, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Quick. For a history screen scrolled past every day.
subtle: { delay: 0.12, rise: 0.6, drop: 3 },
// The all-purpose setting: slow enough to watch the overtake happen.
default: { delay: 0.2, rise: 0.95, drop: 4 },
// A long climb, for the screen that exists to deliver this news.
playful: { delay: 0.26, rise: 1.3, drop: 6 },
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const SAMPLE_SESSIONS: RecordSession[] = [
{ label: "M", value: 5.2 },
{ label: "T", value: 6.8 },
{ label: "W", value: 4.1 },
{ label: "T", value: 6.4 },
{ label: "F", value: 3.9 },
{ label: "S", value: 5.7 },
{ label: "S", value: 9.2 },
];
const PLOT_H = 108;
const PLOT_W = 220;
const GUTTER = 78;
export default function RecordBrokenHighlight({
variant = "default",
eyebrow = "Longest run",
title = "This week",
sessions = SAMPLE_SESSIONS,
previousBest = 7.4,
unit = "km",
recordLabel = "New best",
accent = "#C0397A",
onRecord,
}: RecordBrokenHighlightProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const still = !!reduceMotion;
const latest = sessions[sessions.length - 1]?.value ?? 0;
const ceiling =
Math.max(previousBest, ...sessions.map((session) => session.value)) * 1.08 || 1;
const px = (value: number) => (value / ceiling) * PLOT_H;
const grow = useMotionValue(still ? 1 : 0);
// The marker's height is the larger of the old mark and the live
// value, which is what makes the column look like it is carrying the
// line rather than the line being animated alongside it.
const level = useTransform(grow, (v) => Math.max(previousBest, v * latest));
const markerY = useTransform(level, (v) => -px(v));
const figure = useTransform(level, (v) => v.toFixed(1));
const columnH = useTransform(grow, (v) => px(v * latest));
// Whether the mark has fallen is a fact about the value, so it resets
// during render when the run changes; the effect below owns only the
// animation.
const runKey = `${still}:${latest}:${previousBest}:${cfg.rise}:${cfg.delay}`;
const fresh = { key: runKey, broken: still && latest > previousBest };
const [run, setRun] = useState(fresh);
if (run.key !== runKey) setRun(fresh);
const broken = run.key === runKey ? run.broken : fresh.broken;
useMotionValueEvent(grow, "change", (v) => {
if (!broken && v * latest > previousBest) setRun({ key: runKey, broken: true });
});
const onRecordRef = useRef(onRecord);
useEffect(() => {
onRecordRef.current = onRecord;
}, [onRecord]);
useEffect(() => {
if (broken) onRecordRef.current?.();
}, [broken]);
useEffect(() => {
// Reduced motion: the column is already at its value and the mark is
// already retired. The climb was only the presentation of a result.
if (still) {
grow.set(1);
return;
}
grow.set(0);
const controls = animate(grow, 1, {
duration: cfg.rise,
delay: cfg.delay,
ease: [0.28, 0.7, 0.28, 1],
});
return () => controls.stop();
}, [still, grow, cfg.rise, cfg.delay]);
const gap = 7;
const barW = (PLOT_W - gap * (sessions.length - 1)) / sessions.length;
return (
<div style={{ width: PLOT_W + GUTTER }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 16,
}}
>
<span style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span
style={{
fontSize: 10,
fontWeight: 620,
letterSpacing: "0.09em",
textTransform: "uppercase",
color: tone(45),
}}
>
{eyebrow}
</span>
<span style={{ fontSize: 13.5, fontWeight: 650 }}>{title}</span>
</span>
<motion.span
initial={still ? { opacity: 0 } : { opacity: 0, y: 5 }}
animate={broken ? { opacity: 1, y: 0 } : { opacity: 0, y: still ? 0 : 5 }}
transition={{ duration: still ? 0.18 : 0.28, ease: [0.22, 1, 0.36, 1] }}
style={{
marginLeft: "auto",
padding: "3px 9px",
borderRadius: 999,
fontSize: 10.5,
fontWeight: 640,
color: accent,
background: `color-mix(in srgb, ${accent} 14%, transparent)`,
}}
>
{recordLabel}
</motion.span>
</div>
<div style={{ position: "relative", height: PLOT_H }}>
{/* Earlier sessions. They already happened, so they hold
perfectly still and give the newest column something to be
measured against. */}
<div
style={{
position: "absolute",
left: 0,
bottom: 0,
width: PLOT_W,
height: PLOT_H,
display: "flex",
alignItems: "flex-end",
gap,
}}
>
{sessions.map((session, index) => {
const newest = index === sessions.length - 1;
return (
<span
key={`${session.label}-${index}`}
style={{
position: "relative",
width: barW,
height: PLOT_H,
display: "flex",
alignItems: "flex-end",
}}
>
{newest ? (
// Height, not scale: a column growing genuinely is a
// size change, and scaling would smear its top radius.
<motion.span
style={{
width: "100%",
height: columnH,
borderRadius: 5,
background: accent,
}}
/>
) : (
<span
style={{
width: "100%",
height: px(session.value),
borderRadius: 5,
background: tone(13),
}}
/>
)}
</span>
);
})}
</div>
{/* The mark that was beaten. It fades in where the record line
used to sit, drops a few pixels and stays as a reference. */}
<motion.div
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: broken ? 1 : 0, y: broken && !still ? cfg.drop : 0 }}
transition={{
duration: still ? 0.18 : 0.34,
ease: [0.22, 1, 0.36, 1],
}}
style={{
position: "absolute",
left: 0,
bottom: px(previousBest),
width: PLOT_W + GUTTER,
// Zero height with centred children: the row's own text box
// would otherwise lift the rule off the value it marks.
height: 0,
display: "flex",
alignItems: "center",
gap: 8,
pointerEvents: "none",
}}
>
<span
style={{
width: PLOT_W,
height: 0,
borderTop: `1.5px dashed ${tone(24)}`,
}}
/>
<span
style={{
fontSize: 10,
color: tone(42),
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{`Previous ${previousBest.toFixed(1)}`}
</span>
</motion.div>
{/* The record line itself. Bottom-anchored and lifted by a motion
value, so its position is the value — not an approximation of
it scheduled to arrive at roughly the same time. */}
<motion.div
style={{
position: "absolute",
left: 0,
bottom: 0,
width: PLOT_W + GUTTER,
// Zero height, children centred: the line's midpoint is the
// value, so the marker sits exactly on the column's top edge
// rather than half a text box above it.
height: 0,
display: "flex",
alignItems: "center",
gap: 8,
y: markerY,
pointerEvents: "none",
}}
>
<span style={{ width: PLOT_W, height: 1.5, background: accent }} />
<span
style={{
display: "flex",
alignItems: "baseline",
gap: 3,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{/* Driven by the same value as the line. The figure changes;
its size never does. */}
<motion.span
style={{ fontSize: 14, fontWeight: 680, color: accent, lineHeight: 1 }}
>
{figure}
</motion.span>
<span style={{ fontSize: 10, fontWeight: 600, color: accent }}>
{unit}
</span>
</span>
</motion.div>
</div>
<div style={{ display: "flex", gap, width: PLOT_W, marginTop: 7 }}>
{sessions.map((session, index) => (
<span
key={`${session.label}-${index}`}
style={{
width: barW,
textAlign: "center",
fontSize: 9.5,
fontWeight: 600,
color: index === sessions.length - 1 ? accent : tone(38),
}}
>
{session.label}
</span>
))}
</div>
</div>
);
}About this pattern
A new best, shown as an overtake rather than an announcement. The latest session rises to its value and the best-ever marker is carried up on top of it the moment it is passed — the marker's height is the value, so the line and the figure beside it physically cannot disagree with the data or arrive a beat apart from it. The mark that was beaten is not deleted: it fades in as a dashed reference, drops a few pixels and stays, because a record only means anything next to the thing it beat. No springs anywhere. A measurement that overshoots is a number the person did not earn, so the climb is a decelerating tween that stops exactly where the result stops, and the figure counts on the same value without ever changing size.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Activity summary
A session summary calling out a new best against the previous mark on the same axis.
Related patterns
- Milestone ReachedA milestone card rises over the page and one soft band of light crosses it — no confetti.
- First Time BadgeThe first time an account does something, a small rosette settles beside the row and a marker line follows.
- Badge UnlockAn earned badge lands and one band of light crosses its face — a single pass, then still.