All patterns

Background Refresh Hint

A two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.

loadingsubtlecalmautomatic · finite · starter · ~2.4s
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.

301 lines · react + motion only
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Background Refresh Hint
 *
 * Data is being refetched under content someone is already reading. The
 * only thing that moves is a tinted band travelling along the panel's top
 * edge — two pixels tall, never over a word. The content keeps full
 * opacity, nothing is disabled, and when the fetch lands the band retires
 * and the timestamp crossfades to "just now".
 *
 * 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 `refreshing` to drive it from your own
 * query state and `children` to wrap your own content.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type BackgroundRefreshHintProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Drive this from your query state. Left undefined, the panel runs its
   *  own refetch cycle so the file works as-is. */
  refreshing?: boolean;
  /** Only consulted while `refreshing` is undefined. */
  refreshMs?: number;
  /** Only consulted while `refreshing` is undefined. */
  restMs?: number;
  /** Panel heading. */
  title?: string;
  /** Timestamp shown between refreshes. */
  restLabel?: string;
  /** Timestamp shown immediately after one lands. */
  freshLabel?: string;
  /** Your content. Falls back to embedded sample content. */
  children?: ReactNode;
  /** Accent for the edge band. */
  accent?: string;
  /** Width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Seconds for one pass along the edge. */
  passSeconds: number;
  /** Band width as a share of the edge. */
  bandWidth: number;
  /** Peak opacity of the band. */
  peak: number;
  /** Crossfade for the timestamp swap. */
  swapSeconds: number;
};

// Nothing here springs: a hint that overshoots is asking to be looked at,
// which is the opposite of the job. Variants change the pace and the
// weight of the band, never its travel path.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Slow and faint — visible in peripheral vision only. For a panel
  // that polls every few seconds.
  subtle: {
    passSeconds: 1.6,
    bandWidth: 0.34,
    peak: 0.5,
    swapSeconds: 0.24,
  },
  // The all-purpose setting.
  default: {
    passSeconds: 1.15,
    bandWidth: 0.42,
    peak: 0.75,
    swapSeconds: 0.28,
  },
  // Brisker and brighter, for a manual pull where someone is waiting for
  // the result they asked for.
  playful: {
    passSeconds: 0.85,
    bandWidth: 0.5,
    peak: 0.95,
    swapSeconds: 0.32,
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const ACCENT = "#7C7CF0";

export default function BackgroundRefreshHint({
  variant = "default",
  refreshing,
  refreshMs = 2400,
  restMs = 2000,
  title = "Inbox",
  restLabel = "Updated 4 min ago",
  freshLabel = "Updated just now",
  children,
  accent = ACCENT,
  width = 320,
}: BackgroundRefreshHintProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [selfRefreshing, setSelfRefreshing] = useState(true);
  const [landed, setLanded] = useState(false);

  // Uncontrolled by default so the file runs on its own; the moment a
  // caller passes `refreshing`, this cycle stays out of the way.
  useEffect(() => {
    if (refreshing !== undefined) return;
    const timer = setTimeout(
      () => {
        setSelfRefreshing((value) => !value);
        setLanded(true);
      },
      selfRefreshing ? refreshMs : restMs
    );
    return () => clearTimeout(timer);
  }, [refreshing, selfRefreshing, refreshMs, restMs]);

  const isRefreshing = refreshing ?? selfRefreshing;
  const stamp = !isRefreshing && landed ? freshLabel : restLabel;

  return (
    <div
      // `aria-busy` carries the state that the band communicates visually;
      // nothing is removed from the accessibility tree while it runs.
      aria-busy={isRefreshing}
      style={{
        position: "relative",
        width,
        borderRadius: 14,
        background: tone(4),
        border: `1px solid ${tone(10)}`,
        overflow: "hidden",
      }}
    >
      {/* The whole indicator: two pixels along the top edge, inside the
          panel's own rounding. It never overlaps content, so reading is
          uninterrupted while the data underneath is replaced. */}
      <div
        aria-hidden
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          right: 0,
          height: 2,
          overflow: "hidden",
          pointerEvents: "none",
        }}
      >
        <AnimatePresence>
          {isRefreshing &&
            (reduceMotion ? (
              // Reduced motion keeps the fact and drops the travel: a
              // still tinted edge says "refreshing" without anything
              // crossing the screen.
              <motion.span
                key="still"
                initial={{ opacity: 0 }}
                animate={{ opacity: cfg.peak * 0.7 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.2, ease: "easeOut" }}
                style={{
                  position: "absolute",
                  inset: 0,
                  display: "block",
                  background: accent,
                }}
              />
            ) : (
              <motion.span
                key="band"
                initial={{ opacity: 0 }}
                animate={{
                  opacity: cfg.peak,
                  // x is a share of the band's own width, so the travel
                  // scales with whatever the panel measures.
                  x: ["-110%", "245%"],
                }}
                exit={{ opacity: 0 }}
                transition={{
                  opacity: { duration: 0.22, ease: "easeOut" },
                  x: {
                    duration: cfg.passSeconds,
                    ease: "easeInOut",
                    repeat: Infinity,
                    repeatDelay: 0.12,
                  },
                }}
                style={{
                  position: "absolute",
                  top: 0,
                  bottom: 0,
                  left: 0,
                  display: "block",
                  width: `${cfg.bandWidth * 100}%`,
                  background: `linear-gradient(90deg, transparent 0%, ${accent} 50%, transparent 100%)`,
                }}
              />
            ))}
        </AnimatePresence>
      </div>

      <div style={{ padding: "14px 16px 16px" }}>
        <div
          style={{
            display: "flex",
            alignItems: "baseline",
            justifyContent: "space-between",
            gap: 10,
          }}
        >
          <span style={{ fontSize: 13, fontWeight: 650 }}>{title}</span>

          {/* The stamp swaps in a reserved box: same line, same size, so
              the header cannot shift when the wording changes. */}
          <span
            aria-live="polite"
            style={{
              position: "relative",
              display: "inline-block",
              fontSize: 11.5,
              opacity: 0.5,
              whiteSpace: "nowrap",
            }}
          >
            <span style={{ visibility: "hidden" }}>{restLabel}</span>
            <AnimatePresence initial={false}>
              <motion.span
                key={stamp}
                initial={{ opacity: 0, y: reduceMotion ? 0 : 4 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
                transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
                style={{ position: "absolute", inset: 0, textAlign: "right" }}
              >
                {stamp}
              </motion.span>
            </AnimatePresence>
          </span>
        </div>

        <div style={{ marginTop: 12 }}>{children ?? <SampleContent />}</div>
      </div>
    </div>
  );
}

/** Embedded sample so the component renders something real with zero
 *  props. Replace it by passing `children`. */
function SampleContent() {
  const entries = [
    { from: "Billing", subject: "Invoice 2841 is ready", at: "09:12" },
    { from: "Dana Whitfield", subject: "Re: renewal terms", at: "08:47" },
    { from: "Deploys", subject: "Release 4.19 is live", at: "08:02" },
  ];
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
      {entries.map((entry) => (
        <div
          key={entry.subject}
          style={{
            display: "flex",
            alignItems: "center",
            gap: 10,
            padding: "8px 0",
            borderTop: `1px solid ${tone(8)}`,
          }}
        >
          <span
            aria-hidden
            style={{
              width: 24,
              height: 24,
              borderRadius: 7,
              flexShrink: 0,
              background: tone(9),
            }}
          />
          <span style={{ minWidth: 0 }}>
            <span style={{ display: "block", fontSize: 12, fontWeight: 600 }}>
              {entry.from}
            </span>
            <span style={{ display: "block", fontSize: 11.5, opacity: 0.55 }}>
              {entry.subject}
            </span>
          </span>
          <span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.4 }}>
            {entry.at}
          </span>
        </div>
      ))}
    </div>
  );
}

About this pattern

Cached content is on screen and being read; a refetch is in flight behind it. The wrong answer is to dim the panel or replace it with a waiting state, because the content is still true. Instead the entire indicator lives in a two-pixel strip along the top edge, inside the panel's own rounding, where a tinted band travels from side to side. Content keeps full opacity, nothing is disabled, and `aria-busy` carries the same fact the band does. When the fetch lands the band retires and the timestamp crossfades to 'just now' inside a reserved box, so the header never shifts. Nothing springs here — a hint that overshoots is asking to be looked at, which is the opposite of the job.

Polling a feedRevalidating cached dataLive inboxAuto-refreshing table view

Where it shows up

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

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    The list stays readable while new mail is fetched behind it.

Related patterns