All patterns

Offline Cached Only

When the network drops, downloaded rows stay crisp while the rest dim in a wave down the list.

empty-statescalmminimalautomatic · finite · starter · ~0.7s
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.

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

Downloaded media libraryDocuments available offlineAirplane mode listPartial content while disconnected