Skeleton Loaders
Build skeleton loading states that are pixel-perfect matches of real content.
Transparent Text Technique
Use real HTML elements (<p>, <span>, <h2>, <button>) with the same font classes as the real component, plus shimmer + transparency classes. This makes skeletons inherit exact line-height, font-size, and weight — producing pixel-perfect dimensions without hardcoded h-*/w-* values.
Shimmer class constant
Define a shared class string at the top of the skeleton component:
const shimmer =
'animate-shimmer rounded-sm bg-linear-to-r from-slate-950 via-slate-900 to-slate-950 bg-size-[200%_100%] text-transparent select-none';
Text elements
Copy the real component's element type and font classes, add shimmer, use placeholder text of similar character count:
import {twJoin} from 'tailwind-merge';
// Real component
<p className="truncate text-sm font-semibold text-white">{data.name}</p>
<p className="text-xs text-slate-400">{data.value}</p>
// Skeleton
<p className={twJoin('truncate text-sm font-semibold', shimmer)}>Name</p>
<p className={twJoin('text-xs', shimmer)}>Example value</p>
Non-text elements (images, icons, avatars)
Keep as empty divs with the shimmer class — no text needed:
<div className={twJoin('size-14 shrink-0', shimmer)} />
Interactive elements (buttons)
Use the real element type with tabIndex={-1} to prevent focus:
<button
className={twJoin('w-full py-2 text-xs font-medium', shimmer)}
tabIndex={-1}
type="button"
>
Click me
</button>
200ms Delay Pattern
Avoid skeleton flash on fast loads. Show stale/empty content for 200ms before revealing the skeleton:
const [showSkeleton, setShowSkeleton] = useState(false);
// Valid Effect: synchronizing component state with a timer (external system).
// The cleanup prevents a state update after unmount.
useEffect(() => {
const timer = setTimeout(() => setShowSkeleton(true), 200);
return () => clearTimeout(timer);
}, []);
if (!showSkeleton) return null; // or return stale content
return <MySkeleton />;
When to Use
- Lazy-loaded panels (side panels, detail views)
- Fetcher-driven content swaps
- Async data sections (lists, profiles, detail views)
- Route transitions with pending loader data
Full Example Implementation
import {twJoin} from 'tailwind-merge';
const shimmer =
'animate-shimmer rounded-sm bg-linear-to-r from-slate-950 via-slate-900 to-slate-950 bg-size-[200%_100%] text-transparent select-none';
const ExampleSkeleton = () => (
<div className="border border-slate-700 bg-slate-900">
<div className="flex items-center gap-3 p-3">
<div className={twJoin('size-14 shrink-0', shimmer)} />
<div className="min-w-0 flex-1">
<p className={twJoin('truncate text-sm font-semibold', shimmer)}>
Name
</p>
<p className={twJoin('truncate text-xs', shimmer)}>Example value</p>
</div>
</div>
</div>
);
export default ExampleSkeleton;