Suspense-Style Fallback Card — Vanilla JS Loading Boundary Pattern
Suspense-Style Data Fetch Fallback · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="sf-card" id="sfCard">
<div class="sf-fallback" id="sfFallback">
<div class="sf-avatar"></div>
<div class="sf-lines"><div class="sf-line sf-l1"></div><div class="sf-line sf-l2"></div><div class="sf-line sf-l3"></div></div>
</div>
<div class="sf-content" id="sfContent" hidden>
<img class="sf-real-avatar" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='48' height='48'%3E%3Ccircle cx='24' cy='24' r='24' fill='%236366f1'/%3E%3Ctext x='24' y='30' font-size='18' text-anchor='middle' fill='white' font-family='sans-serif'%3EJP%3C/text%3E%3C/svg%3E" alt="">
<div>
<div class="sf-name">Jordan Park</div>
<div class="sf-bio">Product designer — resolved after a simulated fetch, exactly like a component read from a Suspense-wrapped data source would.</div>
</div>
</div>
</div>
<button type="button" class="sf-retry" id="sfRetry">Refetch</button>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;padding:24px}
.sf-card{width:100%;max-width:380px;min-height:88px;background:#151f34;border:1px solid #223055;border-radius:14px;padding:18px;display:flex;align-items:center}
.sf-fallback{display:flex;align-items:center;gap:14px;width:100%}
.sf-avatar{width:48px;height:48px;border-radius:50%;flex-shrink:0;background:linear-gradient(90deg,#1c2846 25%,#2a3a63 37%,#1c2846 63%);background-size:400% 100%;animation:sfShimmer 1.5s ease-in-out infinite}
.sf-lines{flex:1;display:flex;flex-direction:column;gap:8px}
.sf-line{height:10px;border-radius:5px;background:linear-gradient(90deg,#1c2846 25%,#2a3a63 37%,#1c2846 63%);background-size:400% 100%;animation:sfShimmer 1.5s ease-in-out infinite}
.sf-l1{width:40%}.sf-l2{width:90%}.sf-l3{width:75%}
@keyframes sfShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
.sf-content{display:flex;align-items:center;gap:14px;width:100%;animation:sfFadeIn .35s ease both}
@keyframes sfFadeIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.sf-real-avatar{width:48px;height:48px;border-radius:50%;flex-shrink:0}
.sf-name{font-size:14.5px;font-weight:800;margin-bottom:4px}
.sf-bio{font-size:12.5px;color:#94a3b8;line-height:1.5}
.sf-retry{background:#6366f1;color:#fff;border:none;border-radius:10px;padding:9px 18px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit}
.sf-retry:hover{background:#4f46e5}
.sf-retry:disabled{opacity:.5;cursor:not-allowed}var fallback = document.getElementById('sfFallback');
var content = document.getElementById('sfContent');
var retryBtn = document.getElementById('sfRetry');
// This mirrors what a React <Suspense fallback={<Skeleton />}> boundary does:
// while the "resource" (here, a fake fetch) is unresolved, render the
// fallback; the instant it resolves, swap to the real content. There is no
// framework here — it's the same fallback-then-resolve pattern done by hand
// with plain visibility toggles, so the mechanic is visible and portable.
function fetchProfile() {
fallback.hidden = false;
content.hidden = true;
retryBtn.disabled = true;
// Stand in for a real request — replace with your actual fetch()/promise.
return new Promise(function (resolve) {
setTimeout(resolve, 1600);
}).then(function () {
fallback.hidden = true;
content.hidden = false;
retryBtn.disabled = false;
});
}
retryBtn.addEventListener('click', function () { fetchProfile(); });
fetchProfile();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Suspense-Style Fallback Card — The Vanilla-JS Version of a Suspense Boundary

React's <Suspense fallback={<Skeleton />}> pattern shows a fallback UI while a component's data is unresolved, then swaps to the real component the instant it resolves — no manual loading-state plumbing inside the component itself. This snippet demonstrates that exact visual pattern without React: a fallback skeleton and the real content both exist in the DOM from the start, and a single fetchProfile() function toggles which one is visible based on whether the simulated "resource" has resolved — the same boundary-and-swap mechanic, written by hand.
Two pre-built states, one visibility toggle
Both the .sf-fallback skeleton and the .sf-content real markup exist in the HTML from page load; only their hidden attribute changes. fetchProfile() shows the fallback and hides the content, waits on a promise standing in for a real request, and on resolve flips hidden the other way. This mirrors what Suspense does structurally — the fallback and the resolved tree are two things React can swap between, not a single element mutating its own inner state — which is why this pattern generalizes so cleanly to any framework or no framework at all.
Why this is useful without React
If you like the Suspense mental model — declare a fallback, declare the real content, let a resolve event handle the swap — but you're not using React (or you're building a plain HTML component), this is the same idea implemented directly: a promise-returning function that owns exactly one job, showing the right state at the right time. It reads as a clean vanilla-JS pattern for "loading boundary" logic that a framework component or hook could wrap later.
A real promise, not a fake delay
fetchProfile returns an actual Promise, resolved here by a setTimeout standing in for fetch(). Because it's a real promise, callers can await it, chain .then(), or race it against a timeout — the same ergonomics as the data-fetching function a Suspense-compatible resource would wrap, just without the resource-cache machinery React needs internally.
Retry included
A "Refetch" button re-invokes fetchProfile(), re-showing the fallback and disabling itself until the new promise resolves — demonstrating that the boundary isn't a one-shot animation, it's a reusable toggle driven by any promise you hand it. Pair the fallback shape with a skeleton loader for other layouts, or an ai thinking loader for a chat-specific fallback.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA skeleton fallback shows immediately, then resolves into a real profile card.
- 2Watch the swapAfter ~1.6s the fallback hides and the real content fades in.
- 3Click RefetchThe fallback reappears and the cycle repeats, mirroring a re-suspended boundary.
- 4Replace the fake promiseSwap the setTimeout in fetchProfile for your real fetch()/API call.
- 5Swap the fallback shapeChange .sf-fallback's markup to match whatever content it's standing in for.
- 6Reuse the patternCopy the two-elements-plus-toggle structure for any other async section.
Real-world uses
Common Use Cases
fetchProfile() rather than a bare timeout.Got questions?
Frequently Asked Questions
React's <Suspense fallback={<Skeleton/>}> shows the fallback element while a wrapped component's data dependency is unresolved, then swaps to the real component tree once it resolves — without the component itself managing a loading flag. This snippet reproduces that structurally: both the fallback and real content exist as separate elements from the start, and one function toggles which is visible based on a promise's resolution, rather than one element mutating an internal loading state.
No — that's the point. fetchProfile() is a plain function returning a plain Promise, and the "swap" is just toggling the hidden attribute on two existing DOM elements. It works in any HTML page, any framework, or no framework, which makes it useful if you like the Suspense mental model but aren't using React.
Replace the setTimeout-wrapped Promise inside fetchProfile with your real fetch() or async function, keeping the same shape: show the fallback and hide the content at the start, await/resolve the real request, then hide the fallback and show the content in a .then() or after an await. Add a .catch() to show an error state if you need one — this demo omits it for clarity.
Pre-building both states and toggling visibility (rather than injecting the real content's markup only after it resolves) keeps the swap instant and avoids a layout jump from building new DOM nodes at resolve time. It also mirrors how Suspense conceptually treats the fallback and the resolved tree as two ready-made branches, not a dynamically constructed one.
In React, this is literally what <Suspense> plus a Suspense-compatible data source gives you for free. In Vue or Angular (no built-in Suspense-equivalent for arbitrary promises), wrap the same idea in a small component: hold a resolved boolean in state/signal, render the fallback markup when false and the real content when true, and flip it in a promise .then()/await, exactly like fetchProfile() does here.