All patterns

Content Placeholder Pulse

Placeholder blocks rise and fall together on one slow cadence, so the region reads as dormant rather than busy.

loadingcalmsubtleautomatic · looping · starter · ~2.0s
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.

185 lines · react + motion only
import type { CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Content Placeholder Pulse
 *
 * Placeholder blocks that breathe in unison on one slow cadence. The
 * whole group is driven by a single opacity animation on the wrapper
 * rather than one per block, which is the entire design: staggered
 * placeholders read as a sweep and drifting ones read as a fault,
 * whereas a group that rises and falls together reads as one surface
 * waiting. It loops for as long as the request takes, so the cadence
 * sits deliberately below the rate at which motion starts pulling the
 * eye back.
 *
 * 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.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ContentPlaceholderPulseProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Whether the request is still in flight. */
  active?: boolean;
  /** Number of body lines in the sample block. */
  lines?: number;
  /** Placeholder fill. A translucent neutral, so it reads in either theme. */
  placeholderColor?: string;
  /** Announced to assistive technology while the pulse runs. */
  loadingLabel?: string;
  /** Panel width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** Opacity at the bottom of the breath. */
  from: number;
  /** Opacity at the top of the breath. */
  to: number;
  /** Seconds for a full down-and-up cycle. */
  seconds: number;
};

// Quality rule: opacity only, and one cycle slow enough to be ignorable.
// Nothing here scales — a placeholder that grows and shrinks suggests
// the content will do the same when it lands, and it will not. Variants
// change the depth and pace of the breath, nothing else.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely perceptible. For a page with several waiting regions, where
  // a deeper breath would turn the whole layout into a heartbeat.
  subtle: {
    from: 0.72,
    to: 1,
    seconds: 2.6,
  },
  // The all-purpose setting: clearly alive, still ignorable.
  default: {
    from: 0.56,
    to: 1,
    seconds: 2,
  },
  // A deeper, slightly quicker breath for a single panel that is the
  // only thing on screen.
  playful: {
    from: 0.42,
    to: 1,
    seconds: 1.6,
  },
};

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

/** Theme-adaptive neutral: mixing the inherited text color with
 *  transparent yields a surface and border that are correctly toned in
 *  either theme. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const LINE_WIDTHS = ["100%", "94%", "88%", "66%", "78%", "52%"];

export default function ContentPlaceholderPulse({
  variant = "default",
  active = true,
  lines = 4,
  placeholderColor = PLACEHOLDER_COLOR,
  loadingLabel = "Loading content",
  width = 320,
}: ContentPlaceholderPulseProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const breathing = active && !reduceMotion;

  // Reduced motion keeps the placeholders and their meaning — this
  // region is waiting — and drops the breath entirely, resting at the
  // midpoint rather than freezing at either extreme.
  const restOpacity = reduceMotion ? (cfg.from + cfg.to) / 2 : cfg.to;

  const block = (style: CSSProperties) => ({
    ...style,
    background: placeholderColor,
    borderRadius: 6,
  });

  return (
    <div
      role="status"
      aria-busy={active}
      style={{
        width,
        padding: 18,
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(4),
        position: "relative",
      }}
    >
      {/* Screen readers get the state in words; the pulse is decoration
          and is hidden from them entirely. */}
      <span
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {active ? loadingLabel : ""}
      </span>

      {/* One animation for the entire group. Per-block timelines drift
          apart over a long wait — a few hundred milliseconds of skew and
          the panel stops looking like it is breathing and starts looking
          like it is glitching. */}
      <motion.div
        aria-hidden
        animate={
          breathing ? { opacity: [cfg.to, cfg.from, cfg.to] } : { opacity: restOpacity }
        }
        transition={
          breathing
            ? { duration: cfg.seconds, repeat: Infinity, ease: "easeInOut" }
            : { duration: 0.3, ease: "easeOut" }
        }
        style={{ display: "flex", flexDirection: "column", gap: 16 }}
      >
        <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
          <div style={block({ width: 40, height: 40, borderRadius: 12 })} />
          <div
            style={{
              flex: 1,
              display: "flex",
              flexDirection: "column",
              gap: 8,
            }}
          >
            <div style={block({ width: "58%", height: 11 })} />
            <div style={block({ width: "34%", height: 9 })} />
          </div>
        </div>

        <div style={{ display: "flex", flexDirection: "column", gap: 11 }}>
          {Array.from({ length: Math.max(1, lines) }, (_, index) => (
            <div
              key={index}
              style={block({
                width: LINE_WIDTHS[index % LINE_WIDTHS.length],
                height: 10,
              })}
            />
          ))}
        </div>

        <div style={{ display: "flex", gap: 8 }}>
          <div style={block({ width: 72, height: 24, borderRadius: 12 })} />
          <div style={block({ width: 54, height: 24, borderRadius: 12 })} />
        </div>
      </motion.div>
    </div>
  );
}

About this pattern

The quietest waiting state in the library, and the one to reach for when the wait might be long. Every block is driven by a single opacity cycle on their shared wrapper, not one animation each: offset blocks read as a sweep and blocks that drift out of phase read as a fault, whereas a group that rises and falls as one reads as a single surface holding its place. The cadence is deliberately slow — around two seconds for a full breath — because this loops for the entire request and anything brisker starts pulling the eye back every time it comes around. Opacity is the only property in play; a placeholder that grows and shrinks promises the real content will do the same, and it will not.

Profile panelDetail paneSidebar loadingSlow request

Where it shows up

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

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    Channel and profile panes hold softly fading blocks while their data is fetched.

Related patterns