Skeleton Loading States That Don’t Suck
Most skeleton loaders are lazy rectangles with a shimmer animation. They technically show “something is loading” but they don’t actually help users. They cause layout shift. They stay visible too long. They don’t match what’s coming.
We spent two weeks getting our skeleton architecture right. Here’s what we learned.
The Core Problem
When a user clicks a button to open a modal, there are actually two async operations happening:
- Chunk loading — The JavaScript for that modal needs to download (code splitting)
- Data fetching — The modal needs to fetch its data from the API
Most apps conflate these. They show a skeleton during both phases. This creates two problems:
- Skeletons visible for 2-3 seconds (feels slow)
- Layout shifts when the skeleton doesn’t match the loaded content
Our insight: separate these concerns. Skeletons should only show during chunk loading (~50-200ms). Once the component loads, it handles its own data fetching internally.
The LazyShow Pattern
SolidJS has lazy() and <Suspense>. They work, but Suspense catches ALL async operations within its boundary—including data fetches from solid-query. This means your skeleton reappears every time data refreshes. Not what we want.
We created LazyShow instead:
function LazyShow<T extends Component>(props: {
load: () => Promise<{ default: T }>;
fallback: JSX.Element;
children: (Comp: T) => JSX.Element;
}) {
const [mod] = createResource(props.load);
return (
<Show when={mod()} fallback={props.fallback}>
{(m) => props.children(m().default)}
</Show>
);
}
The key is using <Show> instead of <Suspense>. The Show component only triggers the fallback while the module is loading—it doesn’t catch data refetches from solid-query the way Suspense would.
Usage in our modal registry:
const modalRegistry = {
settings: {
component: () => (
<LazyShow
load={() => import("@/components/modals/Settings")}
fallback={<SettingsSkeletonModal />}
>
{(Settings) => <Settings />}
</LazyShow>
),
},
// ... other modals
}
Skeleton Design Principles
We learned these the hard way:
1. Match the actual structure
A skeleton should mirror exactly what’s coming. If your modal has four tabs, your skeleton has four tabs. If your chat messages have avatars on the left, your skeleton messages have avatar placeholders on the left.
export default function SettingsSkeleton() {
return (
<div class="flex flex-col h-full">
{/* Tab bar with actual icons, not rectangles */}
<div class="flex border-b border-border px-2 pt-2">
<div class="flex items-center gap-1.5 px-3 py-2 border-b-2 border-primary">
<Crown size={16} class="opacity-60" />
<span class="text-sm font-medium opacity-60">Account</span>
</div>
{/* Other tabs... */}
</div>
{/* Content placeholders matching actual layout */}
</div>
)
}
We use actual Lucide icons at reduced opacity. Not gray rectangles. This creates visual continuity when the real content appears.
2. Use the same CSS classes
Our chat skeleton initially used max-w-3xl mx-auto while real messages used .messages-list and .message-pair classes with different padding. Result: visible layout shift on load.
The fix was obvious once we spotted it. Use identical classes:
// Bad: skeleton-specific layout
<div class="max-w-3xl mx-auto">
// Good: same classes as real content
<div class="messages-list">
<div class="message-pair">
3. Slow, subtle animations
We started with Tailwind’s default animate-pulse. It was too aggressive—there was visible scaling.
Turns out we had a custom @keyframes pulse in our CSS that used scale(1.1). It was overriding Tailwind’s opacity-only animation.
/* This was causing skeleton elements to grow by 10% */
@keyframes pulse {
50% {
transform: scale(1.1);
}
}
/* Fixed: renamed to avoid conflict */
@keyframes scale-pulse {
50% {
transform: scale(1.1);
}
}
Now we use animate-[pulse_2s_ease-in-out_infinite] with bg-muted/60. Two seconds is long enough to feel calm, not anxious.
4. Synchronous rendering
Your skeleton must render synchronously. If it’s lazy-loaded, you’ve defeated the entire purpose.
We originally used Kobalte’s Skeleton component. Problem: Kobalte was being lazy-loaded. Users saw nothing, then the skeleton, then the content. Two loading phases visible.
Plain Tailwind divs render synchronously as part of the main bundle. No delays.
The Loading Timeline
Here’s what actually happens when someone clicks “Settings”:
User clicks
│
▼
┌─────────────────────────────────────────┐
│ SYNCHRONOUS (instant, <16ms) │
│ 1. Modal state updates │
│ 2. Modal wrapper renders │
│ 3. Skeleton content renders │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ ASYNC - Chunk Loading (~50-200ms) │
│ - Skeleton visible during this phase │
│ - Dynamic import() fetches JS chunk │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ COMPONENT RENDER │
│ - Real component replaces skeleton │
│ - Component triggers its own fetches │
│ - Component handles its own states │
└─────────────────────────────────────────┘
The skeleton is visible for 50-200ms on a fast connection. That’s short enough that it feels instant. The component then handles data fetching with its own internal loading states (spinners, empty states, whatever makes sense for that UI).
Chat Feed Skeletons: Avoiding Scroll Jank
Chat interfaces have extra complexity. Messages need to:
- Scroll to bottom on initial load
- Preserve scroll position when loading older messages
- Not cause layout shift during streaming
Scroll-to-bottom timing
We added a scrollOnReady prop that triggers scroll when content transitions from skeleton to messages:
<CustomScrollToBottom
scrollOnReady={hasContent() && !isLoading()}
>
Without this, the scroll happened during the skeleton phase—before messages existed.
Pagination scroll preservation
When users click “Load more messages,” we manually preserve their scroll position:
let scrollPreservationData = null;
const fetchNextPageWithScrollPreservation = () => {
const container = document.querySelector(".messages-scroll-view");
scrollPreservationData = {
scrollHeight: container.scrollHeight,
scrollTop: container.scrollTop,
};
fetchNextPage();
};
// After fetch completes
createEffect(
on(
() => conversationHistory.isFetchingNextPage,
(isFetching, wasFetching) => {
if (!isFetching && wasFetching && scrollPreservationData) {
requestAnimationFrame(() => {
const delta =
container.scrollHeight - scrollPreservationData.scrollHeight;
container.scrollTop = scrollPreservationData.scrollTop + delta;
scrollPreservationData = null;
});
}
},
),
);
We capture scroll state before fetching, calculate the height delta after new content renders, and adjust position accordingly. This is necessary because we intentionally disable overflow-anchor for more predictable scroll control during streaming.
What We Measured
After implementing this architecture across our modals:
| Modal | Skeleton | Chunk Size | Perceived Load |
|---|---|---|---|
| Image Viewer | Inline simple | 8 KB | Instant |
| Memories | Custom search/cards | 14 KB | Instant |
| Personality | Custom assessments | 43 KB | Instant |
| Memory Network | Inline simple | 568 KB | Brief skeleton (vis-network is heavy) |
“Instant” means skeleton visible for <200ms on a typical connection. The Memory Network modal—which bundles the vis-network graphing library at 568 KB—shows its skeleton noticeably longer, but that’s acceptable because users accessing the knowledge graph expect a heavier feature.
Takeaways
-
Separate chunk loading from data fetching. Skeletons for the former only.
-
Match your skeleton to your content exactly. Same classes, same structure, same icons at reduced opacity.
-
Render skeletons synchronously. No lazy-loaded UI library components.
-
Slow down your animations. Two seconds, opacity only, no scaling.
-
Handle scroll behavior explicitly. Don’t trust browser defaults for chat interfaces.
The difference between a good loading experience and a bad one often comes down to 200ms and a few CSS classes. It’s worth getting right.
Have questions about skeleton architecture? I’m always happy to talk shop.
Our LazyShow pattern and other SolidJS techniques are available as a reusable skill:
npx skills add https://github.com/omniaura/skills --skill solidjs-patterns
— Peyton