Background Refresh Hint
A two-pixel tinted band travels the panel's top edge while data refetches, without interrupting reading.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
The list stays readable while new mail is fetched behind it.
Related patterns
- Content Placeholder PulsePlaceholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.
- Infinite Scroll FooterA footer spinner fills a slot that was already reserved, and the next page fades in above it.
- Cache Hit InstantCached content gets no entrance at all; only the values that actually changed animate.