Skeleton Develop

Skeleton

Loading wrapper whose placeholder blocks develop into the real content — a masked sweep resolves the text instead of swapping it.

Install
npx shadcn add https://design.helpmarq.com/r/skeleton-develop.json
Source
registry/core/skeleton-develop/component.tsx
"use client";

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";

// Loading wrapper whose placeholder DEVELOPS into the real content, like a
// print coming up in a tray: on `loading` -> false both layers are stacked and
// a soft-edged alpha mask sweeps downward — the ghost blocks dissolve and
// settle upward as the children un-blur behind them, top first. No swap, no
// flash. While loading, each block carries a token-derived sweep (a recessed
// --background trough followed by a --muted crest over a --border bed), not the
// white-to-grey shine every skeleton on the internet ships.
//
// Layout comes from the declarative `blocks` prop — the children are never
// measured, because while loading they are not mounted at all. Everything
// animating is CSS (compositor-friendly, zero rAF); React only runs the
// three-state phase machine. prefers-reduced-motion drops to an instant swap
// with still blocks.

export type SkeletonBlock =
  /** One emphasised bar. `width` is a fraction of the container, 0..1. */
  | { kind: "heading"; width?: number }
  /** `lines` body bars; the last one is short, the way a paragraph ends. */
  | { kind: "text"; lines?: number; width?: number }
  /** Avatar / icon disc. `size` in px. */
  | { kind: "circle"; size?: number }
  /** Media or card placeholder. `height` in px, `width` a fraction. */
  | { kind: "box"; height?: number; width?: number }
  /** Horizontal group — an avatar beside its two lines, a row of chips. */
  | { kind: "row"; gap?: number; align?: "start" | "center"; items: SkeletonBlock[] };

export interface DevelopSkeletonProps {
  /** While true the placeholder is shown; flipping it false runs the develop. */
  loading: boolean;
  /** The real content. Not rendered — and so never measured — while loading. */
  children: ReactNode;
  /** Placeholder layout. Defaults to a heading over three lines of text. */
  blocks?: SkeletonBlock[];
  /** Duration of the develop transition, ms. */
  developMs?: number;
  /** Announced to screen readers while loading. */
  loadingLabel?: string;
  className?: string;
}

type Phase = "loading" | "developing" | "ready";

const DEFAULT_BLOCKS: SkeletonBlock[] = [
  { kind: "heading", width: 0.55 },
  { kind: "text", lines: 3 },
];

const CSS = `
.ns-dev-bar{position:relative;overflow:hidden;background:var(--border)}
.ns-dev-bar::after{
  content:"";position:absolute;inset:0;
  background-image:linear-gradient(100deg,
    transparent 22%,
    color-mix(in oklab, var(--background) 55%, transparent) 39%,
    color-mix(in oklab, var(--muted) 24%, transparent) 52%,
    transparent 74%);
  transform:translateX(-100%);
  /* the delay is a custom property, not animation-delay on the host element:
     animation-delay does not inherit into ::after, custom properties do */
  animation:ns-dev-sweep 2600ms cubic-bezier(.42,0,.2,1) var(--ns-dev-delay,0ms) infinite;
}
@keyframes ns-dev-sweep{0%{transform:translateX(-100%)}64%,100%{transform:translateX(100%)}}

/* Alpha masks only — the gradients below are opacity ramps, not ink. */
.ns-dev-content{
  -webkit-mask-image:linear-gradient(to bottom,rgb(0 0 0/1) 0 33%,rgb(0 0 0/0) 47% 100%);
  mask-image:linear-gradient(to bottom,rgb(0 0 0/1) 0 33%,rgb(0 0 0/0) 47% 100%);
  -webkit-mask-size:100% 300%;mask-size:100% 300%;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  animation:ns-dev-resolve var(--ns-dev-ms) cubic-bezier(.22,1,.36,1) both;
}
@keyframes ns-dev-resolve{
  from{-webkit-mask-position:0 100%;mask-position:0 100%;filter:blur(7px) contrast(.7)}
  to{-webkit-mask-position:0 0%;mask-position:0 0%;filter:blur(0) contrast(1)}
}
.ns-dev-ghost{
  transform-origin:top center;
  -webkit-mask-image:linear-gradient(to bottom,rgb(0 0 0/0) 0 33%,rgb(0 0 0/1) 47% 100%);
  mask-image:linear-gradient(to bottom,rgb(0 0 0/0) 0 33%,rgb(0 0 0/1) 47% 100%);
  -webkit-mask-size:100% 300%;mask-size:100% 300%;
  -webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
  animation:ns-dev-dissolve var(--ns-dev-ms) cubic-bezier(.22,1,.36,1) both;
}
@keyframes ns-dev-dissolve{
  from{-webkit-mask-position:0 100%;mask-position:0 100%;filter:blur(0);transform:scaleY(1)}
  to{-webkit-mask-position:0 0%;mask-position:0 0%;filter:blur(5px);transform:scaleY(.975)}
}
@media (prefers-reduced-motion: reduce){
  .ns-dev-bar::after{animation:none;background-image:none}
  .ns-dev-content,.ns-dev-ghost{animation:none;-webkit-mask-image:none;mask-image:none;filter:none}
}
`;

function renderBlocks(blocks: SkeletonBlock[], seq: { n: number }): ReactNode[] {
  return blocks.map((block, i) => {
    // negative delays offset each bar's sweep so the panel reads as one wave
    // travelling through it rather than every bar flashing in lockstep
    const bar = (width: number, height: number, radius: string) => (
      <div
        className={`ns-dev-bar ${radius}`}
        style={
          {
            width: `${width * 100}%`,
            height,
            "--ns-dev-delay": `${-140 * seq.n++}ms`,
          } as CSSProperties
        }
      />
    );

    switch (block.kind) {
      case "heading":
        return <div key={i}>{bar(block.width ?? 0.55, 20, "rounded-sm")}</div>;
      case "text": {
        const lines = Math.max(1, block.lines ?? 3);
        const width = block.width ?? 1;
        return (
          <div key={i} className="flex flex-col gap-3">
            {Array.from({ length: lines }, (_, l) => (
              // a paragraph's last line stops short — without it the block
              // reads as a table, not prose
              <div key={l}>{bar(l === lines - 1 ? width * 0.62 : width, 12, "rounded-full")}</div>
            ))}
          </div>
        );
      }
      case "circle": {
        const size = block.size ?? 40;
        return (
          <div
            key={i}
            className="ns-dev-bar shrink-0 rounded-full"
            style={
              { width: size, height: size, "--ns-dev-delay": `${-140 * seq.n++}ms` } as CSSProperties
            }
          />
        );
      }
      case "box":
        return <div key={i}>{bar(block.width ?? 1, block.height ?? 120, "rounded-md")}</div>;
      case "row":
        return (
          <div
            key={i}
            className="flex w-full"
            style={{ gap: block.gap ?? 12, alignItems: block.align === "center" ? "center" : "flex-start" }}
          >
            {block.items.map((item, j) => (
              // a disc keeps its own size; everything else shares the rest
              <div key={j} className={item.kind === "circle" ? "shrink-0" : "min-w-0 flex-1"}>
                {renderBlocks([item], seq)}
              </div>
            ))}
          </div>
        );
    }
  });
}

export function DevelopSkeleton({
  loading,
  children,
  blocks = DEFAULT_BLOCKS,
  developMs = 900,
  loadingLabel = "Loading",
  className = "",
}: DevelopSkeletonProps) {
  const [reduced, setReduced] = useState(false);
  const [phase, setPhase] = useState<Phase>(loading ? "loading" : "ready");
  // content that mounted already-loaded must not play a develop it never earned
  const wasLoading = useRef(loading);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onChange = () => setReduced(mq.matches);
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useEffect(() => {
    if (loading) {
      wasLoading.current = true;
      setPhase("loading");
      return;
    }
    if (!wasLoading.current) {
      setPhase("ready");
      return;
    }
    wasLoading.current = false;
    if (reduced) {
      setPhase("ready");
      return;
    }
    setPhase("developing");
    const id = window.setTimeout(() => setPhase("ready"), developMs);
    // flipping back to loading mid-develop lands here first: the pending
    // settle is dropped, so the ghost cannot un-hide a half-developed layer
    return () => window.clearTimeout(id);
  }, [loading, developMs, reduced]);

  const developing = phase === "developing";
  const vars = { "--ns-dev-ms": `${developMs}ms` } as CSSProperties;

  return (
    <div className={`relative ${className}`} style={vars} aria-busy={phase !== "ready"}>
      <style>{CSS}</style>

      {phase !== "loading" && (
        <div className={developing ? "ns-dev-content" : undefined}>{children}</div>
      )}

      {phase !== "ready" && (
        <div
          aria-hidden
          className={[
            "flex w-full flex-col gap-4",
            developing ? "ns-dev-ghost pointer-events-none absolute inset-0" : "",
          ].join(" ")}
        >
          {renderBlocks(blocks, { n: 0 })}
        </div>
      )}

      {phase === "loading" && (
        <span role="status" className="sr-only">
          {loadingLabel}
        </span>
      )}
    </div>
  );
}
Build spec

A loading-state wrapper, not a grey rectangle: <DevelopSkeleton loading={isLoading} blocks={...}>{content}</DevelopSkeleton>. While loading it renders placeholder blocks described by a declarative `blocks` prop (heading / text with a line count / circle / box / row groups) — the children are never measured, because they are not mounted at all until the data arrives. Each bar sits on the --border token and carries a slow sweep built from a recessed --background trough followed by a --muted crest, staggered bar to bar by a negative animation delay so the panel reads as one wave travelling through it, rather than the white-to-grey shine every other skeleton ships. When `loading` flips false the two layers stack and the placeholder develops into the content like a print coming up in a tray: a soft-edged alpha mask sweeps downward, the ghost blocks blur out and settle upward while the children un-blur and gain contrast behind them, top first, with no swap and no flash. Everything animating is CSS, so there is no rAF loop and no canvas; React only runs a three-state phase machine (loading / developing / ready), and flipping back to loading mid-develop cancels the pending settle rather than stranding a half-developed layer. Content that mounts already-loaded skips the transition entirely. The wrapper is aria-busy while loading with an sr-only status label; prefers-reduced-motion drops to still blocks, no sweep, and an instant swap.

Tags
skeletonloadingplaceholdertransitioncss-animation