kyle.berry
All components

Component · Jun 3, 2026

Skeleton crossfade

A loading-state study with a minimum display time, so the skeleton never flashes and the content never jumps.

Skeleton crossfade

loading…
component.tsx
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'

/**
 * A perceived-performance study: skeleton -> content with a minimum display
 * time. If data arrives before MIN_SKELETON_MS, the skeleton is held so it
 * never flashes. If data is slower, content swaps in the moment it lands.
 */

const MIN_SKELETON_MS = 450
const CROSSFADE_MS = 240

interface LoadStats {
  fetchMs: number
  heldMs: number
}

interface Article {
  title: string
  excerpt: string
  meta: string
}

const ARTICLE: Article = {
  title: 'Why loading states deserve design time',
  excerpt:
    'A skeleton that flashes for 80ms is worse than no skeleton at all. The fix is a minimum display time, and the numbers matter more than the pattern.',
  meta: '4 min read · Perception',
}

/** Simulated fetch: sometimes faster than the minimum, sometimes slower. */
function fakeLatency(): number {
  return 150 + Math.random() * 1250
}

export default function SkeletonCrossfade() {
  const [phase, setPhase] = useState<'loading' | 'ready'>('loading')
  const [stats, setStats] = useState<LoadStats | null>(null)
  const timersRef = useRef<number[]>([])

  const clearTimers = useCallback(() => {
    for (const id of timersRef.current) window.clearTimeout(id)
    timersRef.current = []
  }, [])

  // Only schedules timers; state changes happen inside the timer callbacks,
  // so this is safe to run from the mount effect.
  const schedule = useCallback(() => {
    const startedAt = performance.now()
    const latency = fakeLatency()

    const fetchTimer = window.setTimeout(() => {
      const fetchMs = Math.round(performance.now() - startedAt)
      const heldMs = Math.max(0, Math.round(MIN_SKELETON_MS - fetchMs))

      const holdTimer = window.setTimeout(() => {
        setPhase('ready')
        setStats({ fetchMs, heldMs })
      }, heldMs)
      timersRef.current.push(holdTimer)
    }, latency)
    timersRef.current.push(fetchTimer)
  }, [])

  const reload = () => {
    clearTimers()
    setPhase('loading')
    setStats(null)
    schedule()
  }

  useEffect(() => {
    schedule()
    return clearTimers
  }, [schedule, clearTimers])

  const ready = phase === 'ready'

  return (
    <div className="mx-auto w-md max-w-full">
      <style>{`
        @keyframes skeleton-shimmer {
          from { background-position: 200% 0; }
          to { background-position: -200% 0; }
        }
        .skeleton-line {
          background: linear-gradient(
            90deg,
            var(--color-surface-hover) 25%,
            var(--color-border) 50%,
            var(--color-surface-hover) 75%
          );
          background-size: 200% 100%;
        }
        @media (prefers-reduced-motion: no-preference) {
          .skeleton-line { animation: skeleton-shimmer 1.6s linear infinite; }
        }
        @media (prefers-reduced-motion: reduce) {
          .crossfade-layer { transition: none !important; }
        }
      `}</style>

      <div
        aria-busy={!ready}
        className="grid overflow-hidden rounded-(--radius-card) border border-(--color-border) bg-(--color-surface) p-5"
      >
        {/* Skeleton layer: mirrors the content dimensions so the swap is layout-stable. */}
        <div
          aria-hidden="true"
          className="crossfade-layer col-start-1 row-start-1 transition-opacity"
          style={{ opacity: ready ? 0 : 1, transitionDuration: `${CROSSFADE_MS}ms` }}
        >
          <div className="skeleton-line h-3 w-24 rounded" />
          <div className="skeleton-line mt-4 h-5 w-4/5 rounded" />
          <div className="skeleton-line mt-3 h-4 w-full rounded" />
          <div className="skeleton-line mt-2 h-4 w-11/12 rounded" />
          <div className="skeleton-line mt-2 h-4 w-2/3 rounded" />
        </div>

        {/* Content layer */}
        <div
          className="crossfade-layer col-start-1 row-start-1 transition-opacity"
          style={{ opacity: ready ? 1 : 0, transitionDuration: `${CROSSFADE_MS}ms` }}
          aria-hidden={!ready}
        >
          <p className="font-mono text-[10px] tracking-[0.14em] text-(--color-fg-subtle) uppercase">
            {ARTICLE.meta}
          </p>
          <h3 className="mt-3 text-lg leading-snug font-medium text-(--color-fg)">
            {ARTICLE.title}
          </h3>
          <p className="mt-2 text-sm leading-relaxed text-(--color-fg-muted)">{ARTICLE.excerpt}</p>
        </div>
      </div>

      <div className="mt-4 flex items-center justify-between gap-4">
        <button
          type="button"
          onClick={reload}
          className="rounded-md border border-(--color-border-strong) bg-(--color-surface) px-3 py-1.5 text-sm text-(--color-fg) transition-colors hover:bg-(--color-surface-hover)"
        >
          Reload
        </button>
        <span className="font-mono text-[10px] text-(--color-fg-subtle)" aria-live="polite">
          {stats
            ? `fetch ${stats.fetchMs}ms · held ${stats.heldMs}ms · min ${MIN_SKELETON_MS}ms`
            : 'loading…'}
        </span>
      </div>
    </div>
  )
}

Skeletons have a failure mode nobody designs for: the fast response. When data arrives in 80ms, a naive implementation flashes the skeleton for a single frame and then slams content into place. It reads as a glitch. The slow path gets all the design attention, and the fast path (which is most loads, most days) gets the worst experience.

The fix here is a minimum display time. Once the skeleton is visible, it stays visible for at least 450ms even if the data came back sooner. If the fetch is slower than that, content swaps in the moment it lands, so the floor never adds latency to a genuinely slow response. Hit reload a few times and watch the readout: the simulated latency is randomized, so sometimes the skeleton is held and sometimes it isn't.

The second half of the pattern is layout stability. The skeleton and the content render into the same grid cell, and the skeleton's line widths are drawn to roughly match the content it stands in for. The crossfade is a plain opacity transition between two layers that already occupy identical space, so nothing reflows during the swap. If your skeleton is a different height than your content, the crossfade just relocates the jank instead of removing it.

Reduced motion gets two accommodations: the shimmer animation is only applied inside a prefers-reduced-motion: no-preference block, and the crossfade collapses to an instant swap. A shimmering placeholder is exactly the kind of ambient movement that setting exists to turn off.

One number worth stealing: 450ms. Below roughly 400ms a held skeleton starts to feel like added latency; much above 500ms and fast responses feel throttled. I landed on 450 by feel after trying a range, and I'd tune it per surface rather than treat it as a constant.

Related

Live leaderboardmotion
Sortable listaccessibility
Async comboboxaccessibility