All patterns

Skeleton to Content

Breathing placeholders hand off to the real content in one cross-fade and a small lift.

loadingcalmminimalautomatic · finite · starter · ~1.8s
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.

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

/**
 * Vibary · Skeleton to Content
 *
 * Placeholder blocks breathe while data is in flight, then the real
 * content cross-fades over them and lifts into place.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; pass `loading` to drive it from your own
 * request state and `children` to render your own content.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SkeletonToContentProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Drive this from your request state. Left undefined, the component
   *  reveals itself after `revealAfterMs` so the file runs as-is. */
  loading?: boolean;
  /** Only consulted while `loading` is undefined. */
  revealAfterMs?: number;
  /** Your content. Falls back to embedded sample content. */
  children?: ReactNode;
  /** Block width — px number or any CSS length. */
  width?: number | string;
  /** Placeholder fill. A translucent neutral, so it reads on light and dark. */
  placeholderColor?: string;
  /** Fires once the content has finished arriving. */
  onRevealed?: () => void;
};

type VariantConfig = {
  breathFrom: number;
  breathTo: number;
  breathSeconds: number;
  breathStagger: number;
  lift: number;
  fadeSeconds: number;
  handoff: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the lift springs at or near critical damping, so content
// arrives with at most one soft settle and text never bounces. Variants
// differ in breath depth, travel and speed — not in wobble.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Shallow breath, 4px of travel, overdamped. For dense dashboards
  // where several of these load at once.
  subtle: {
    breathFrom: 0.68,
    breathTo: 0.95,
    breathSeconds: 2.0,
    breathStagger: 0.05,
    lift: 4,
    fadeSeconds: 0.3,
    handoff: 0.06,
    spring: { type: "spring", stiffness: 420, damping: 42 },
  },
  // The all-purpose setting: a readable breath and a lift you notice
  // without watching for it.
  default: {
    breathFrom: 0.55,
    breathTo: 1,
    breathSeconds: 1.6,
    breathStagger: 0.07,
    lift: 8,
    fadeSeconds: 0.34,
    handoff: 0.08,
    spring: { type: "spring", stiffness: 360, damping: 34 },
  },
  // Deeper breath, longer travel — for a single hero panel that owns
  // the screen while it loads.
  playful: {
    breathFrom: 0.45,
    breathTo: 1,
    breathSeconds: 1.3,
    breathStagger: 0.09,
    lift: 12,
    fadeSeconds: 0.38,
    handoff: 0.1,
    spring: { type: "spring", stiffness: 320, damping: 29 },
  },
};

const PLACEHOLDER_COLOR = "rgba(127, 127, 140, 0.18)";

export default function SkeletonToContent({
  variant = "default",
  loading,
  revealAfterMs = 1400,
  children,
  width = 288,
  placeholderColor = PLACEHOLDER_COLOR,
  onRevealed,
}: SkeletonToContentProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [selfRevealed, setSelfRevealed] = useState(false);

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

  const isLoading = loading ?? !selfRevealed;
  const breathing = isLoading && !reduceMotion;
  const lift = reduceMotion ? 0 : cfg.lift;

  // Reduced motion keeps the whole information sequence — placeholders
  // first, content second — and drops only the travel and the breathing.
  const contentTransition = reduceMotion
    ? { duration: 0.2, ease: "easeOut" as const }
    : {
        // Opacity runs on its own quick curve; springing a fade looks muddy.
        opacity: {
          duration: cfg.fadeSeconds,
          ease: "easeOut" as const,
          delay: isLoading ? 0 : cfg.handoff,
        },
        y: { ...cfg.spring, delay: isLoading ? 0 : cfg.handoff },
      };

  return (
    <div
      aria-busy={isLoading}
      style={{ display: "grid", alignItems: "start", width }}
    >
      {/* Skeleton and content share one grid cell. The container is
          therefore sized by the taller of the two from the first frame,
          so the swap can't shove the rest of the page around — a jump
          would undo everything the cross-fade is doing. */}
      <motion.div
        aria-hidden
        animate={{ opacity: isLoading ? 1 : 0 }}
        transition={{ duration: cfg.fadeSeconds, ease: "easeOut" }}
        style={{
          gridArea: "1 / 1",
          display: "flex",
          flexDirection: "column",
          gap: 14,
          pointerEvents: "none",
        }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <Placeholder
            cfg={cfg}
            color={placeholderColor}
            breathing={breathing}
            delayIndex={0}
            style={{ width: 44, height: 44, borderRadius: 12 }}
          />
          <div
            style={{
              flex: 1,
              display: "flex",
              flexDirection: "column",
              gap: 8,
            }}
          >
            <Placeholder
              cfg={cfg}
              color={placeholderColor}
              breathing={breathing}
              delayIndex={1}
              style={{ width: "62%", height: 11 }}
            />
            <Placeholder
              cfg={cfg}
              color={placeholderColor}
              breathing={breathing}
              delayIndex={2}
              style={{ width: "38%", height: 9 }}
            />
          </div>
        </div>
        <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
          {["100%", "96%", "64%"].map((barWidth, index) => (
            <Placeholder
              key={barWidth}
              cfg={cfg}
              color={placeholderColor}
              breathing={breathing}
              delayIndex={3 + index}
              style={{ width: barWidth, height: 10 }}
            />
          ))}
        </div>
      </motion.div>

      {/* Present in the DOM from the start so the grid can measure it,
          but hidden from assistive tech and from the pointer until the
          data it represents is actually there. */}
      <motion.div
        aria-hidden={isLoading}
        initial={{ opacity: 0, y: lift }}
        animate={{ opacity: isLoading ? 0 : 1, y: isLoading ? lift : 0 }}
        transition={contentTransition}
        onAnimationComplete={() => {
          if (!isLoading) onRevealed?.();
        }}
        style={{
          gridArea: "1 / 1",
          pointerEvents: isLoading ? "none" : "auto",
        }}
      >
        {children ?? <SampleContent />}
      </motion.div>
    </div>
  );
}

/** One placeholder block. Local component so the breath timing — the
 *  part worth tuning — lives in exactly one place. */
function Placeholder({
  cfg,
  color,
  breathing,
  delayIndex,
  style,
}: {
  cfg: VariantConfig;
  color: string;
  breathing: boolean;
  delayIndex: number;
  style: CSSProperties;
}) {
  return (
    <motion.div
      animate={
        breathing
          ? { opacity: [cfg.breathFrom, cfg.breathTo, cfg.breathFrom] }
          : { opacity: cfg.breathTo }
      }
      transition={
        breathing
          ? {
              duration: cfg.breathSeconds,
              repeat: Infinity,
              ease: "easeInOut",
              // A small offset per block keeps the panel from pulsing as
              // one slab — it reads as a surface, not a blinking light.
              delay: delayIndex * cfg.breathStagger,
            }
          : { duration: 0.2, ease: "easeOut" }
      }
      style={{ background: color, borderRadius: 6, ...style }}
    />
  );
}

/** Embedded sample so the component renders something real with zero
 *  props. Replace it by passing `children`. */
function SampleContent() {
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
      <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
        <div
          style={{
            width: 44,
            height: 44,
            borderRadius: 12,
            background: "linear-gradient(135deg, #7C7CF0 0%, #4B4BB8 100%)",
            flexShrink: 0,
          }}
        />
        <div style={{ minWidth: 0 }}>
          <div style={{ fontSize: 15, fontWeight: 600, lineHeight: 1.3 }}>
            Q3 Revenue Overview
          </div>
          <div style={{ fontSize: 12.5, opacity: 0.55, marginTop: 3 }}>
            Updated 2 minutes ago
          </div>
        </div>
      </div>
      <p style={{ margin: 0, fontSize: 13, lineHeight: 1.55, opacity: 0.72 }}>
        Subscription revenue grew 12% against last quarter, driven by seat
        expansion in existing accounts. Churn held flat at 1.4%.
      </p>
    </div>
  );
}

About this pattern

The default loading state for anything that fetches: feeds, dashboards, profile panels. Placeholder blocks breathe on a slow cycle so the surface reads as alive rather than frozen, then the real content cross-fades over them and lifts a few pixels into place. Placeholder and content share a single grid cell, so the panel is already the right size before the data lands — the hand-off never shoves the rest of the page around, which is what separates this from simply swapping one block for another.

Feed loadingDashboard panelProfile loadSearch results