All patterns

Personal Record

The newest column overtakes the best-ever marker, which rides up on it while the beaten mark drops away as a reference.

achievementenergeticpremiumautomatic · finite · advanced · ~1.5s
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.

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

New longest run or rideBest sales week on recordFastest build timeHigh score beaten

Where it shows up

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

  • 10:15
    Today
    412 kcalMove8,240Steps9 hrsStand
    HomeSearchActivityProfile
    Activity summary

    A session summary calling out a new best against the previous mark on the same axis.

Related patterns