Offline Cached Only
When the network drops, downloaded rows stay crisp while the rest dim in a wave down the list.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Offline Cached Only
*
* Going offline is not an empty screen — it is a shorter list. Rows that
* are already downloaded stay exactly as they were; the ones that need
* the network dim in a wave down the list, and each row states which it
* is. Nothing is removed and nothing moves, so the reader can still see
* what exists and what will come back.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props; pass `offline` to drive it from
* your own connectivity state.
* Requires the automatic JSX runtime (default since React 17).
*/
export type OfflineItem = {
title: string;
meta: string;
/** True when the item is already on the device. */
cached: boolean;
/** Stands in for cover art, so it stays a literal color. */
swatch: string;
};
export type OfflineCachedOnlyProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your rows. The embedded sample is used when omitted. */
items?: OfflineItem[];
/** Drive this from your connectivity state. Left undefined, the
* component drops the connection itself after `offlineAfterMs`. */
offline?: boolean;
/** Only consulted while `offline` is undefined. */
offlineAfterMs?: number;
/** Strip text while connected and while offline. */
onlineLabel?: string;
offlineLabel?: string;
/** Row markers. */
savedLabel?: string;
unavailableLabel?: string;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** Opacity an unavailable row falls to. Never zero — it still exists. */
dimTo: number;
/** Seconds between one row dimming and the next. */
stagger: number;
fadeSeconds: number;
/** px the offline strip drops in by. */
drop: number;
};
// Quality rule: nothing here springs and nothing scales. The whole state
// change is opacity plus a few pixels on one strip, because a list that
// rearranges itself when the network drops makes the reader lose their
// place at the worst possible moment.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The gentlest reading, for a list someone is mid-scroll in.
subtle: {
dimTo: 0.42,
stagger: 0.035,
fadeSeconds: 0.24,
drop: 3,
},
// A clear wave down the list. All-purpose.
default: {
dimTo: 0.34,
stagger: 0.055,
fadeSeconds: 0.3,
drop: 5,
},
// Deeper and slower, for a library where knowing what is playable
// matters more than the list looking even.
playful: {
dimTo: 0.26,
stagger: 0.08,
fadeSeconds: 0.36,
drop: 8,
},
};
const ITEMS: OfflineItem[] = [
{
title: "Onboarding handbook",
meta: "42 pages",
cached: true,
swatch: "linear-gradient(135deg, #7C7CF0 0%, #4B4BB8 100%)",
},
{
title: "Q3 board deck",
meta: "18 slides",
cached: false,
swatch: "linear-gradient(135deg, #E0A458 0%, #B9762F 100%)",
},
{
title: "Brand guidelines",
meta: "PDF · 6 MB",
cached: true,
swatch: "linear-gradient(135deg, #4FA3A5 0%, #2F6E70 100%)",
},
{
title: "Vendor contract",
meta: "12 pages",
cached: false,
swatch: "linear-gradient(135deg, #C46A8D 0%, #8A3F5E 100%)",
},
];
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps the strip and the row markers correct on light and
* dark pages. Cover swatches stay literal — they stand in for images. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function OfflineCachedOnly({
variant = "default",
items = ITEMS,
offline,
offlineAfterMs = 800,
onlineLabel = "All items available",
offlineLabel = "Offline · showing downloaded items",
savedLabel = "Saved",
unavailableLabel = "Needs network",
width = 320,
}: OfflineCachedOnlyProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfOffline, setSelfOffline] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `offline`, this timer stays out of the way.
useEffect(() => {
if (offline !== undefined) return;
const timer = setTimeout(() => setSelfOffline(true), offlineAfterMs);
return () => clearTimeout(timer);
}, [offline, offlineAfterMs]);
const isOffline = offline ?? selfOffline;
const stagger = reduceMotion ? 0 : cfg.stagger;
const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };
return (
<div style={{ position: "relative", width, boxSizing: "border-box" }}>
{/* Both strip readings share one cell: the header reserves the
taller of the two, so the list below never shifts when the
connection changes. */}
<div
style={{
display: "grid",
padding: "10px 14px",
borderBottom: `1px solid ${tone(9)}`,
}}
>
<motion.div
animate={{ opacity: isOffline ? 0 : 0.5 }}
transition={fade}
style={{
gridArea: "1 / 1",
display: "flex",
alignItems: "center",
gap: 7,
fontSize: 11.5,
}}
>
<span
aria-hidden
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "currentColor",
opacity: 0.7,
}}
/>
{onlineLabel}
</motion.div>
<motion.div
initial={false}
animate={{
opacity: isOffline ? 1 : 0,
y: isOffline || reduceMotion ? 0 : -cfg.drop,
}}
transition={fade}
style={{
gridArea: "1 / 1",
display: "flex",
alignItems: "center",
gap: 7,
fontSize: 11.5,
fontWeight: 560,
}}
>
<CloudOffGlyph />
{offlineLabel}
</motion.div>
</div>
<div>
{items.map((item, index) => {
const dimmed = isOffline && !item.cached;
const delay = index * stagger;
return (
<motion.div
key={item.title}
animate={{ opacity: dimmed ? cfg.dimTo : 1 }}
transition={{ ...fade, delay }}
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "10px 14px",
borderTop: index === 0 ? "none" : `1px solid ${tone(7)}`,
}}
>
<div
aria-hidden
style={{
width: 32,
height: 32,
borderRadius: 8,
background: item.swatch,
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.title}
</div>
<div style={{ fontSize: 11, opacity: 0.5, marginTop: 2 }}>
{item.meta}
</div>
</div>
{/* The marker only exists offline: while connected, saying
"saved" about half the list is noise. */}
<motion.span
initial={false}
animate={{ opacity: isOffline ? 1 : 0 }}
transition={{ ...fade, delay }}
style={{
flexShrink: 0,
fontSize: 10.5,
padding: item.cached ? "3px 8px" : 0,
borderRadius: 999,
whiteSpace: "nowrap",
background: item.cached ? tone(9) : "transparent",
border: item.cached ? `1px solid ${tone(13)}` : "none",
opacity: item.cached ? 1 : 0.7,
}}
>
{item.cached ? savedLabel : unavailableLabel}
</motion.span>
</motion.div>
);
})}
</div>
<span
role="status"
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{isOffline ? offlineLabel : onlineLabel}
</span>
</div>
);
}
/** Line art authored inline: a cloud with a stroke through it, drawn in
* `currentColor` so it inherits the page theme. */
function CloudOffGlyph() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4.6 12.3h6.1a2.9 2.9 0 0 0 .5-5.7 4 4 0 0 0-6.6-2"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
opacity="0.8"
/>
<path
d="M2.6 2.6l10.8 10.8"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
opacity="0.5"
/>
</svg>
);
}About this pattern
Losing the network rarely empties a screen completely — it leaves a partial one, and the useful thing motion can do is say which half is which. Downloaded rows hold their full weight and pick up a small marker; rows that need the network fade to a readable dim in a wave down the list, one after another rather than all at once, so the change reads as a sweep with a cause. Nothing is removed, nothing reorders and nothing scales, because a list that rearranges itself the moment the connection drops loses the reader's place exactly when they can least afford it.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Media player
Without a connection, downloaded tracks stay active while streaming-only rows fade back.
Related patterns
- Deleted Item TombstoneA removed row leaves a stub holding its slot open, with a hairline showing how long undo is on offer.
- Archive EmptyOne fade and a lid settling three pixels onto a box — the least motion a state can have and still arrive.
- Chart No DataThe axes extend from the origin and a flat dashed baseline runs across, so an empty range still looks like a chart.
