interior[.]dev

Async·03.1

Skeleton Swap

Skeleton to content with zero layout shift.

skeleton-swap

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
import { SkeletonSwap } from "@/components/interior/skeleton-swap";

export function ProfileBio({ userId }: { userId: string }) {
  const [bio, setBio] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((u) => alive && setBio(u.bio));
    return () => {
      alive = false;
    };
  }, [userId]);

  return (
    <article className="rounded-[14px] border border-stone-200 p-4 dark:border-white/[0.16]">
      <h3 className="text-[13px] font-medium">Priya Raman</h3>

      <SkeletonSwap
        ready={bio !== null}
        lines={3}
        lineHeight={21}
        label="Profile"
        className="mt-2"
      >
        {bio ? (
          <p className="text-[13.5px] leading-[21px] text-stone-500 dark:text-stone-400">
            {bio}
          </p>
        ) : null}
      </SkeletonSwap>

      <footer className="mt-3 border-t border-stone-200 pt-3 dark:border-white/[0.16]">
        Member since 2019
      </footer>
    </article>
  );
}

Source

components/interior/skeleton-swap.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

const CROSSFADE = {
  type: "spring",
  stiffness: 260,
  damping: 34,
  mass: 0.8,
} as const;

const WIDTHS = [100, 93, 97, 88, 95, 91] as const;

function widthFor(index: number, total: number) {
  if (total > 1 && index === total - 1) return 62;
  return WIDTHS[(index * 7 + 3) % WIDTHS.length];
}

export type UseSkeletonSwapOptions = {
  ready: boolean;
  delay?: number;
  minVisible?: number;
};

export function useSkeletonSwap({
  ready,
  delay = 120,
  minVisible = 380,
}: UseSkeletonSwapOptions) {
  const [visible, setVisible] = useState(false);
  const shownAt = useRef(0);

  useEffect(() => {
    if (!ready) {
      if (visible) return;
      const t = setTimeout(() => {
        shownAt.current = performance.now();
        setVisible(true);
      }, delay);
      return () => clearTimeout(t);
    }

    if (!visible) return;
    const rest = Math.max(0, minVisible - (performance.now() - shownAt.current));
    const t = setTimeout(() => setVisible(false), rest);
    return () => clearTimeout(t);
  }, [ready, visible, delay, minVisible]);

  return { showSkeleton: visible, busy: !ready };
}

export type SkeletonSwapProps = {
  ready: boolean;
  children: React.ReactNode;
  lines?: number;
  lineHeight?: number;
  barHeight?: number;
  reserve?: number;
  delay?: number;
  minVisible?: number;
  label?: string;
  skeleton?: React.ReactNode;
  className?: string;
};

export function SkeletonSwap({
  ready,
  children,
  lines = 3,
  lineHeight = 21,
  barHeight = 9,
  reserve,
  delay = 120,
  minVisible = 380,
  label,
  skeleton,
  className = "",
}: SkeletonSwapProps) {
  const { showSkeleton } = useSkeletonSwap({ ready, delay, minVisible });
  const reduced = useReducedMotion();

  const shell = useRef<HTMLDivElement>(null);
  const body = useRef<HTMLDivElement>(null);
  const [scrollable, setScrollable] = useState(false);

  const box = reserve ?? lines * lineHeight;

  useEffect(() => {
    const el = shell.current;
    const inner = body.current;
    if (!el || typeof ResizeObserver === "undefined") return;

    const check = () => setScrollable(el.scrollHeight - el.clientHeight > 1);
    check();

    const ro = new ResizeObserver(check);
    ro.observe(el);
    if (inner) ro.observe(inner);
    return () => ro.disconnect();
  }, []);

  return (
    <div
      ref={shell}
      aria-busy={!ready}
      aria-label={label}
      // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
      tabIndex={scrollable ? 0 : undefined}
      style={{ height: box }}
      className={`relative grid overflow-y-auto overscroll-contain text-stone-700 dark:text-stone-200 ${className}`}
    >
      <motion.div
        ref={body}
        className="col-start-1 row-start-1 min-w-0"
        initial={false}
        animate={
          reduced
            ? { opacity: showSkeleton ? 0 : 1 }
            : {
                opacity: showSkeleton ? 0 : 1,
                scale: showSkeleton ? 0.99 : 1,
                filter: showSkeleton ? "blur(4px)" : "blur(0px)",
              }
        }
        transition={reduced ? { duration: 0 } : CROSSFADE}
        style={{
          transformOrigin: "top left",
          pointerEvents: showSkeleton ? "none" : undefined,
        }}
      >
        {children}
      </motion.div>

      <AnimatePresence initial={false}>
        {showSkeleton ? (
          <motion.div
            key="skeleton"
            aria-hidden
            className="pointer-events-none col-start-1 row-start-1 w-full self-start"
            initial={reduced ? { opacity: 1 } : { opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(3px)" }}
            transition={reduced ? { duration: 0 } : CROSSFADE}
          >
            {skeleton ?? (
              <div className="w-full">
                {Array.from({ length: lines }, (_, i) => (
                  <div
                    key={i}
                    className="flex items-center"
                    style={{ height: lineHeight }}
                  >
                    <div
                      className="rounded-[5px] bg-stone-200 dark:bg-white/15"
                      style={{
                        height: barHeight,
                        width: `${widthFor(i, lines)}%`,
                      }}
                    />
                  </div>
                ))}
              </div>
            )}
          </motion.div>
        ) : null}
      </AnimatePresence>

      {label ? (
        <span role="status" className="sr-only">
          {ready ? `${label} loaded` : ""}
        </span>
      ) : null}
    </div>
  );
}

Props

ready
boolean

Flips to true when the real content is available. Everything else is timing around this one value.

children
React.ReactNode

The real content. Mount it only once the data exists; the component holds the box either way.

lines3
number

How many text lines the skeleton draws, and how much height it reserves.

lineHeight21
number

Pixel line height of the content this stands in for. Match the text's own leading and the reserve is exact.

barHeight9
number

Height of the bar drawn inside each reserved line. Purely visual; it does not change the reserve.

reservelines * lineHeight
number

Explicit box height in pixels. Use it when the content is not text: media, a chart, a fixed card.

delay120
number

Milliseconds of silence before the skeleton is allowed to paint. A response faster than this never shows one.

minVisible380
number

Once painted, the skeleton stays at least this long, so it cannot blink.

labelundefined
string

Names the region and announces "<label> loaded" once. Omit it and the component stays silent apart from aria-busy.

skeletonundefined
React.ReactNode

Replaces the default bars with your own placeholder shape. Pair it with reserve so the box still matches.

className""
string

Appended last, so callers win on every utility.