All patterns

Cache Hit Instant

Cached content gets no entrance at all; only the values that actually changed animate.

loadingpremiumsubtleautomatic · finite · intermediate · ~1.1s
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.

346 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Cache Hit Instant
 *
 * Cached data is already correct, so it gets no entrance at all — the
 * panel is simply there on the first frame. Motion is spent only on what
 * the revalidation actually changed: the freshness chip, and a wash
 * across the two rows whose numbers moved.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; pass `refreshed` to drive it from your own
 * revalidation state.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type CacheHitInstantProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** True once the background request has returned. Left undefined, the
   *  component revalidates itself after `revalidateMs`. */
  refreshed?: boolean;
  /** Only consulted while `refreshed` is undefined. */
  revalidateMs?: number;
  /** Panel title. */
  title?: string;
  /** Chip text before the revalidation lands. */
  cachedLabel?: string;
  /** Chip text after it lands. */
  freshLabel?: string;
  /** Accent used for the wash and the live dot. A literal state color. */
  accent?: string;
  /** Panel width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Peak opacity of the wash over a changed row. */
  washPeak: number;
  washSeconds: number;
  /** Seconds between one changed row's wash and the next. */
  stagger: number;
  swapSeconds: number;
  dotSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: restraint is the pattern. Nothing enters, nothing scales,
// no row moves — the numbers are text and only ever cross-fade. The one
// spring in the file settles the live dot, and it sits above critical
// damping so it lands without a wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A wash you notice only in peripheral vision. For a dashboard that
  // revalidates every few seconds.
  subtle: {
    washPeak: 0.08,
    washSeconds: 0.8,
    stagger: 0.05,
    swapSeconds: 0.2,
    dotSpring: { type: "spring", stiffness: 620, damping: 46 },
  },
  // Clear enough to follow which rows moved. The all-purpose setting.
  default: {
    washPeak: 0.14,
    washSeconds: 1.1,
    stagger: 0.08,
    swapSeconds: 0.26,
    dotSpring: { type: "spring", stiffness: 520, damping: 42 },
  },
  // A longer, warmer wash — for a report someone is reading rather than
  // monitoring, where the update should be unmissable.
  playful: {
    washPeak: 0.2,
    washSeconds: 1.4,
    stagger: 0.11,
    swapSeconds: 0.3,
    dotSpring: { type: "spring", stiffness: 440, damping: 38 },
  },
};

const ACCENT = "#7C7CF0";

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields a panel, rules and a stale dot that
 *  are correctly toned on light and dark pages. The accent stays literal —
 *  it is what marks the new value. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Embedded sample. Two of the four values moved on revalidation; the
 *  other two are identical, and that is the point — they must not move. */
const METRICS = [
  { label: "Open tickets", cached: "128", next: "131" },
  { label: "Median first reply", cached: "2h 14m", next: "2h 14m" },
  { label: "Resolved today", cached: "47", next: "52" },
  { label: "Satisfaction", cached: "94%", next: "94%" },
];

/** Only the rows that moved are staggered, and their order is fixed here
 *  rather than counted while rendering — a counter mutated inside the map
 *  would give a different answer on a partial re-render. */
const CHANGED_ORDER = METRICS.filter(
  (metric) => metric.cached !== metric.next
).map((metric) => metric.label);

export default function CacheHitInstant({
  variant = "default",
  refreshed,
  revalidateMs = 1500,
  title = "Support overview",
  cachedLabel = "Cached · 4m ago",
  freshLabel = "Updated just now",
  accent = ACCENT,
  width = 320,
}: CacheHitInstantProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [selfFresh, setSelfFresh] = useState(false);

  // Uncontrolled by default so the file runs on its own; the moment a
  // caller passes `refreshed`, this timer stays out of the way.
  useEffect(() => {
    if (refreshed !== undefined) return;
    const timer = setTimeout(() => setSelfFresh(true), revalidateMs);
    return () => clearTimeout(timer);
  }, [refreshed, revalidateMs]);

  const fresh = refreshed ?? selfFresh;
  const fade = { duration: cfg.swapSeconds, ease: "easeOut" as const };

  return (
    <div
      style={{
        position: "relative",
        width,
        padding: "14px 15px 6px",
        borderRadius: 14,
        background: tone(5),
        border: `1px solid ${tone(11)}`,
        boxSizing: "border-box",
      }}
    >
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 12,
        }}
      >
        <span style={{ fontSize: 13.5, fontWeight: 620 }}>{title}</span>

        <span
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 6,
            flexShrink: 0,
          }}
        >
          {/* Two dots in one cell rather than an animated color: mixing a
              translucent neutral into an accent mid-tween goes through mud,
              and a cross-fade is honest about it being a state change. */}
          <span
            aria-hidden
            style={{ display: "grid", placeItems: "center", width: 7, height: 7 }}
          >
            <motion.span
              initial={false}
              animate={{ opacity: fresh ? 0 : 1 }}
              transition={fade}
              style={{
                gridArea: "1 / 1",
                width: 7,
                height: 7,
                borderRadius: "50%",
                background: tone(32),
              }}
            />
            <motion.span
              initial={false}
              animate={{
                opacity: fresh ? 1 : 0,
                scale: fresh || reduceMotion ? 1 : 0.5,
              }}
              transition={{ ...cfg.dotSpring, opacity: fade }}
              style={{
                gridArea: "1 / 1",
                width: 7,
                height: 7,
                borderRadius: "50%",
                background: accent,
              }}
            />
          </span>

          {/* Both chip labels share one cell, so the header reserves the
              wider of the two and the title can never shift under them. */}
          <span
            aria-hidden
            style={{
              display: "grid",
              justifyItems: "end",
              whiteSpace: "nowrap",
              fontSize: 11.5,
            }}
          >
            <motion.span
              initial={false}
              animate={{ opacity: fresh ? 0 : 0.6 }}
              transition={fade}
              style={{ gridArea: "1 / 1" }}
            >
              {cachedLabel}
            </motion.span>
            <motion.span
              initial={false}
              animate={{ opacity: fresh ? 1 : 0 }}
              transition={fade}
              style={{ gridArea: "1 / 1", color: accent, fontWeight: 600 }}
            >
              {freshLabel}
            </motion.span>
          </span>
        </span>
      </div>

      <div style={{ marginTop: 10 }}>
        {METRICS.map((metric, metricIndex) => {
          const changed = metric.cached !== metric.next;
          const delay = changed
            ? CHANGED_ORDER.indexOf(metric.label) * cfg.stagger
            : 0;
          return (
            <div
              key={metric.label}
              style={{
                position: "relative",
                display: "flex",
                alignItems: "baseline",
                justifyContent: "space-between",
                gap: 12,
                padding: "10px 8px",
                margin: "0 -8px",
                borderRadius: 8,
                borderTop: metricIndex === 0 ? "none" : `1px solid ${tone(8)}`,
              }}
            >
              {/* The wash is the whole message: rows that did not change get
                  no overlay at all, so motion means "this is new" and
                  nothing else. */}
              {changed ? (
                <motion.span
                  aria-hidden
                  initial={false}
                  animate={{
                    opacity: fresh
                      ? reduceMotion
                        ? cfg.washPeak * 0.7
                        : [0, cfg.washPeak, 0]
                      : 0,
                  }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : {
                          duration: cfg.washSeconds,
                          delay,
                          times: [0, 0.18, 1],
                          ease: "easeOut",
                        }
                  }
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 8,
                    background: accent,
                    pointerEvents: "none",
                  }}
                />
              ) : null}

              <span
                style={{ position: "relative", fontSize: 12.5, opacity: 0.72 }}
              >
                {metric.label}
              </span>

              {/* Values stack in one cell: the row reserves the wider
                  reading up front, so a number growing a digit cannot nudge
                  its label. Text cross-fades and never scales. */}
              <span
                style={{
                  position: "relative",
                  display: "grid",
                  justifyItems: "end",
                  fontSize: 13,
                  fontWeight: 600,
                  fontVariantNumeric: "tabular-nums",
                  fontFeatureSettings: '"tnum"',
                  whiteSpace: "nowrap",
                }}
              >
                <motion.span
                  initial={false}
                  animate={{ opacity: fresh && changed ? 0 : 1 }}
                  transition={{ ...fade, delay }}
                  style={{ gridArea: "1 / 1" }}
                >
                  {metric.cached}
                </motion.span>
                {changed ? (
                  <motion.span
                    initial={false}
                    animate={{ opacity: fresh ? 1 : 0 }}
                    transition={{ ...fade, delay }}
                    style={{ gridArea: "1 / 1" }}
                  >
                    {metric.next}
                  </motion.span>
                ) : null}
              </span>
            </div>
          );
        })}
      </div>

      <span
        role="status"
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {fresh ? freshLabel : ""}
      </span>
    </div>
  );
}

About this pattern

The restraint pattern. Data served from a cache is already correct, so animating it in is a lie about how long it took — the panel is simply there on the first frame, with no fade, no lift and no placeholder. What earns motion is the revalidation: the freshness chip cross-fades from 'cached' to 'updated', and a short accent wash passes over the one or two rows whose numbers actually moved. Rows that did not change get no overlay at all, which is what makes the wash mean something. Under reduced motion the wash becomes a tint that stays, so 'what is new' survives without a single frame of movement.

Cached dashboardBackground revalidationOffline-first listMetrics panel

Where it shows up

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

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    Views open instantly from local state and quietly reconcile once the server answers.

Related patterns