{"id":205,"date":"2026-10-26T09:43:20","date_gmt":"2026-10-26T09:43:20","guid":{"rendered":"https:\/\/webdevpuneet.com\/blog\/?p=205"},"modified":"2026-10-26T09:43:26","modified_gmt":"2026-10-26T09:43:26","slug":"11-loading-state-examples-you-can-copy","status":"publish","type":"post","link":"https:\/\/webdevpuneet.com\/blog\/11-loading-state-examples-you-can-copy\/","title":{"rendered":"11 Loading State Examples You Can Copy"},"content":{"rendered":"<p><!--\n================================================================\n  POST TITLE (paste into the WordPress title field \u2014 it becomes the page H1)\n  11 Loading State Examples You Can Copy\n\n  SEO TITLE (55 chars, target 50\u201360, max 60 \u2014 \"Optimize SEO\" panel \u2192 SEO TITLE)\n  11 Loading State Examples You Can Copy (HTML, CSS & JS)\n\n  SEO DESCRIPTION (157 chars, target 150\u2013160, max 160 \u2014 \"Optimize SEO\" panel \u2192 SEO DESCRIPTION)\n  11 copy-ready loading states in HTML, CSS and JS: spinners, skeletons, shimmer, progress bars, button loading, optimistic UI, typing dots and blur-up images.\n\n  HOW TO PASTE: click the first line after this comment, press Ctrl+Shift+End, Ctrl+C.\n  In WordPress click \"Type \/ to choose a block\", then Ctrl+V.\n  Fallback: \u22ee menu \u2192 Code editor \u2192 paste.\n================================================================\n--><\/p>\n\n\n<p class=\"wp-block-paragraph\">A loading state is the one piece of UI almost every product ships without much thought, and the one users notice the moment it&#8217;s wrong. A spinner that never resolves reads as broken. A skeleton that doesn&#8217;t match the content it precedes causes a visible jump the instant real data lands. A button that goes quiet with no feedback gets clicked three more times. None of this is exotic \u2014 it&#8217;s a small, well-understood set of patterns, and picking the right one for the right situation is most of the battle. This post is a gallery of eleven distinct loading states, each implemented in plain HTML and CSS with a touch of vanilla JavaScript, so you can see exactly how each one is built and when it earns its place over the others.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every demo below is <strong>live and interactive<\/strong> right here in the article \u2014 click the trigger button on each one and watch the actual pattern play out over a couple of seconds, the same way it would against a real request. Every embed also has a <strong>Fork &amp; Edit<\/strong> button that opens the exact code, already running, in <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\">My Code<\/a> \u2014 webdevpuneet&#8217;s free in-browser editor. Change a duration, swap a color, delete a transition \u2014 that&#8217;s a faster way to understand why a loading state feels right than any explanation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">New to building these from scratch? The <a href=\"https:\/\/webdevpuneet.com\/css-playground\/\">CSS Playground<\/a> is a free, click-based, in-browser course that covers the animations, transforms and transitions every pattern below is built from, one guided step at a time.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why the right loading state matters more than it looks like it should<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Perceived performance is a different thing from actual performance, and loading states are almost entirely about the former. A request that takes 1.5 seconds with a well-designed loading state can feel faster than one that takes 900 milliseconds with none \u2014 because a blank screen or a frozen button reads as &#8220;did this work?&#8221; long before it reads as &#8220;this is slow.&#8221; The patterns below split into a few families: pure motion (spinners, progress bars) that reassures the user something is happening; structural placeholders (skeletons, shimmer) that reduce the jump when content finally arrives; and state-driven patterns (button loading, optimistic UI) that keep a specific control honest about what it&#8217;s doing. Knowing which family a given moment in your UI belongs to is what determines which demo below to reach for.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. Classic Rotating Spinner<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; to trigger a fake 1.9 second request and watch the ring spin, then resolve into a checkmark.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/1.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Classic Rotating Spinner Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.spinner {\n  width: 44px; height: 44px;\n  border: 4px solid #e3e5ea;\n  border-top-color: #6366f1;\n  border-radius: 50%;\n  animation: spin 0.8s linear infinite;\n}\n@keyframes spin { to { transform: rotate(360deg); } }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A spinner is just a circle with one differently-colored border edge, rotated continuously \u2014 the &#8220;loading&#8221; read comes entirely from motion, not from any actual information about progress. That&#8217;s both its strength and its limit: because it conveys nothing about how much is left, it&#8217;s the right tool exactly when there&#8217;s nothing to convey \u2014 a request whose duration you can&#8217;t estimate ahead of time, like a network round trip. <code>border-top-color<\/code> differing from the other three sides is what gives the eye a fixed point to track as it spins; without that contrast the animation reads as a static ring. Keeping the animation on <code>transform<\/code> rather than something like rotating background positions is also what keeps it running smoothly on the GPU instead of forcing layout recalculation on every frame, which matters once several spinners are on screen at once.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> short, unpredictable waits where you have no useful percentage to show \u2014 a button click, a small inline fetch, a modal opening. <strong>Tip:<\/strong> never let a spinner spin indefinitely with no timeout or fallback message; past 8-10 seconds it stops reading as &#8220;loading&#8221; and starts reading as &#8220;broken.&#8221;<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">2. Skeleton Screen Placeholder<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 the gray placeholder blocks pulse in the exact shape of the final card, then swap for the real content with no layout shift.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/2.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Skeleton Screen Placeholder Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.skel {\n  background: #e7e9ee;\n  border-radius: 6px;\n  animation: pulse 1.1s ease-in-out infinite;\n}\n@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A skeleton screen replaces the spinner&#8217;s &#8220;something is happening, somewhere&#8221; with &#8220;here is the shape of what&#8217;s coming.&#8221; Each placeholder block is sized to match the real element it stands in for \u2014 an avatar-sized circle, a title-height bar, a body-width bar \u2014 so the layout the user sees during loading is the same layout they&#8217;ll see once content lands. That&#8217;s the entire point: nothing shifts, resizes, or jumps when the swap happens, because the skeleton already occupies the correct footprint. The gentle opacity pulse exists purely to distinguish &#8220;this is a loading placeholder&#8221; from &#8220;this is a plain gray box that might be a bug&#8221; \u2014 without it, a skeleton on a slow connection can look like broken CSS rather than a deliberate loading state.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> content-heavy cards, feeds, and profile-style layouts where you know the final shape ahead of time. <strong>Tip:<\/strong> the placeholder&#8217;s dimensions have to genuinely match the real content&#8217;s \u2014 a skeleton line that&#8217;s narrower or shorter than the text it precedes still causes the exact layout jump skeletons exist to prevent.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3. Shimmer Sweep<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 a light band sweeps left to right across the placeholder blocks, a step up from a flat pulsing gray.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/3.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Shimmer Sweep Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.shimmer {\n  background: linear-gradient(90deg, #e7e9ee 25%, #f4f5f8 37%, #e7e9ee 63%);\n  background-size: 400% 100%;\n  animation: shimmer 1.3s ease-in-out infinite;\n}\n@keyframes shimmer {\n  0% { background-position: 100% 50%; }\n  100% { background-position: 0% 50%; }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Shimmer is a skeleton with a moving highlight instead of a pulsing opacity \u2014 the gradient is four times wider than the element itself, and animating <code>background-position<\/code> slides that wide gradient across the visible box, so a lighter band appears to travel through it. The reason this reads as more &#8220;alive&#8221; than the plain pulse in demo #2 is directional motion: a pulse fades the whole shape in and out uniformly, while a sweep gives the eye something to follow left to right, which is closer to how reading and scanning actually work. It&#8217;s a pure CSS effect \u2014 no JavaScript needed to animate it, only to swap the skeleton block for real content once the fake request resolves. Popularized by content feeds like LinkedIn and Facebook, it&#8217;s become the default &#8220;premium&#8221; skeleton treatment.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> any skeleton screen where a slightly more polished, branded loading feel is worth the extra few lines of CSS over a plain pulse. <strong>Tip:<\/strong> keep the gradient&#8217;s light band subtle \u2014 a shimmer that&#8217;s too bright or too fast reads as distracting rather than reassuring, especially when several skeleton cards are shimmering on screen at once.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">4. Determinate Progress Bar<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Start upload&#8221; \u2014 the bar fills to an actual known percentage instead of just animating forever, so you always know how much is left.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/4.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Determinate Progress Bar Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var pct = 0;\nvar timer = setInterval(function () {\n  pct += 4 + Math.random() * 14;   \/\/ real code: bytes uploaded so far\n  fill.style.width = Math.min(pct, 100) + '%';\n  pctLabel.textContent = Math.round(Math.min(pct, 100)) + '%';\n  if (pct &gt;= 100) clearInterval(timer);\n}, 180);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A determinate progress bar only works when you genuinely know a percentage \u2014 bytes uploaded out of total bytes, files processed out of total files, steps completed out of total steps. The CSS itself is trivial (a track div and a fill div whose <code>width<\/code> is set from JavaScript with a short <code>transition<\/code> so each jump animates smoothly instead of snapping); the actual design decision is what number drives it. In the demo, the increments are deliberately uneven \u2014 real uploads don&#8217;t move at a perfectly constant rate, and a bar that fills in exactly equal steps every frame subconsciously reads as fake, the same way a countdown timer that&#8217;s too round feels staged. Giving users a real percentage lets them make a decision a spinner never can \u2014 whether to wait it out or come back later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> file uploads, multi-step imports, and any process where a genuine percentage or step count exists. <strong>Tip:<\/strong> never fake a determinate bar with an arbitrary timer when you don&#8217;t actually have progress data \u2014 an indeterminate bar (next demo) is the honest choice, and a fake percentage that stalls at 94% erodes trust faster than no percentage at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">5. Indeterminate Progress Bar<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 a band slides back and forth with no percentage at all, because the actual duration isn&#8217;t known ahead of time.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/5.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Indeterminate Progress Bar Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.band {\n  position: absolute; top: 0; left: 0; height: 100%;\n  width: 35%; border-radius: 999px; background: #6366f1;\n  animation: slide 1.1s ease-in-out infinite;\n}\n@keyframes slide {\n  0% { left: -35%; }\n  100% { left: 100%; }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is visually similar to demo #4 \u2014 same track, same fill color \u2014 but structurally closer to the spinner in demo #1: a fixed-width band moves back and forth with no relationship to actual progress, because none exists yet to report. It&#8217;s the correct choice specifically for requests where a percentage would be a lie \u2014 a database query, a search, a server-side job whose duration genuinely varies run to run. The bar shape communicates &#8220;this kind of operation&#8221; (a linear process, not a point-in-time action) even without a number attached to it, which is a useful middle ground between a bare spinner and a fully numeric progress bar. Swapping between demo #4 and #5&#8217;s markup is literally a one-line change \u2014 whether <code>width<\/code> is driven by real data or an animation \u2014 which makes it easy to pick the honest one for whatever data you actually have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> operations whose duration can&#8217;t be estimated up front but are still linear, ongoing processes rather than a single instant \u2014 page transitions, search queries, background jobs. <strong>Tip:<\/strong> pair it with a status message that changes over time (&#8220;Connecting\u2026&#8221; then &#8220;Processing\u2026&#8221;) if the wait tends to run long \u2014 it gives the same reassurance a percentage would, without inventing a number you don&#8217;t have.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">6. Button Loading State<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Save changes&#8221; \u2014 the button itself becomes the loading indicator: disabled, a spinner in place of its icon, then a confirmed state.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/6.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Button Loading State Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>saveBtn.disabled = true;\nsaveBtn.className = 'saveBtn saving';\nicon.innerHTML = '&lt;span class=\"spinnerSm\"&gt;&lt;\/span&gt;';\nlabel.textContent = 'Saving\u2026';\n\nsetTimeout(function () {\n  saveBtn.className = 'saveBtn done';\n  icon.textContent = '\u2713';\n  label.textContent = 'Saved';\n  saveBtn.disabled = false;\n}, 1600);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Rather than showing a separate spinner elsewhere on the page, the button that triggered the action becomes the loading indicator directly \u2014 its icon swaps for a small inline spinner, its label changes to a present-tense verb, and it&#8217;s disabled so a second click can&#8217;t fire a duplicate request. This solves a specific, common bug: a save button with no visual feedback gets clicked again (and again) by a user who isn&#8217;t sure the first click registered, which is exactly how double form submissions happen. Setting <code>disabled<\/code> is what actually prevents that; the spinner and label change are what make the disabled state legible instead of just looking unresponsive. Fixing the button&#8217;s <code>min-width<\/code> also matters here \u2014 swapping &#8220;Save changes&#8221; for &#8220;Saving\u2026&#8221; and then &#8220;Saved&#8221; changes the text length each time, and without a minimum width the button itself visibly resizes with every state change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> any form submission, save action, or single-click operation where a duplicate click would cause a real problem. <strong>Tip:<\/strong> always disable the button itself, not just show a spinner next to it \u2014 a spinner with a still-clickable button underneath is a common source of duplicate-submit bugs in production.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">7. Optimistic UI Update<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click the heart a few times \u2014 it updates instantly, before any &#8220;server&#8221; reply. About 1 in 4 clicks simulates a failed request and rolls back, so you can see both outcomes.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/7.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Optimistic UI Update Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>var wasLiked = liked, wasCount = count;\nliked = !liked;                       \/\/ update the UI FIRST\ncount = liked ? count + 1 : count - 1;\nrender();\n\nfakeRequest().then(function (ok) {\n  if (!ok) { liked = wasLiked; count = wasCount; render(); }  \/\/ roll back\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Optimistic UI inverts the usual order of operations: instead of showing a loading state and waiting for a response before updating anything, it updates the interface immediately, assuming the request will succeed, and quietly rolls back only if it doesn&#8217;t. For fast, high-frequency, low-stakes actions \u2014 likes, follows, toggles, reactions \u2014 this removes the loading state from the equation almost entirely, which is why these interactions feel instantaneous on most modern social apps. The trade-off is real, though: the demo deliberately fails about a quarter of the time to make the rollback visible, snapping the heart and count back to exactly what they were before, with a short message explaining why. That rollback path isn&#8217;t optional \u2014 an optimistic update with no failure handling just silently lies to the user on the requests that don&#8217;t go through.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> low-stakes, easily reversible actions where the vast majority of requests succeed \u2014 likes, favorites, toggles, drag-and-drop reordering. <strong>Tip:<\/strong> never use optimistic UI for anything hard to undo or high-consequence, like a payment or a delete \u2014 the brief window where the UI shows something that hasn&#8217;t actually happened yet is the wrong trade for actions where being wrong is expensive.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">8. Skeleton-to-Content Crossfade<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 skeleton and real content are stacked in the same box and crossfade, so the card&#8217;s height never jumps.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/8.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Skeleton-to-Content Crossfade Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.card { position: relative; height: 168px; }   \/* fixed height, both layers fit it *\/\n.layer { position: absolute; inset: 0; transition: opacity 0.45s ease; }\n.skelLayer { opacity: 1; }\n.realLayer { opacity: 0; }\n.card.loaded .skelLayer { opacity: 0; }\n.card.loaded .realLayer { opacity: 1; }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Demo #2 swaps the skeleton&#8217;s markup for real content directly, which is simple but produces an instant, un-animated cut the moment the data lands. This pattern keeps both versions in the DOM at once, stacked exactly on top of each other with <code>position: absolute; inset: 0<\/code> inside a card whose own height is fixed, and animates only their <code>opacity<\/code>. Because the card&#8217;s height never depends on which layer is visible, adding a toggled <code>loaded<\/code> class produces a smooth 450ms crossfade instead of a jump cut, with zero risk of the classic skeleton failure mode \u2014 a placeholder shorter or taller than its real content causing a jarring resize. The cost is a genuinely fixed container height, which only works when you know the final content&#8217;s dimensions ahead of time (an image at a known aspect ratio, a card with a capped line count).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> cards, thumbnails, and any component where you know the exact final dimensions and want the transition itself to feel considered, not abrupt. <strong>Tip:<\/strong> this technique breaks down the moment content height is unpredictable \u2014 variable-length text, for instance \u2014 since a fixed-height container will either clip long content or leave visible gaps under short content.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">9. Pulsing Typing Indicator<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Send message&#8221; \u2014 three dots bounce in the reply bubble while a response is &#8220;typed,&#8221; then the dots are replaced by the actual reply text.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/9.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Pulsing Typing Indicator Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.dot {\n  width: 7px; height: 7px; border-radius: 50%; background: #6b7080;\n  animation: bounce 1s ease-in-out infinite;\n}\n.dot:nth-child(2) { animation-delay: 0.15s; }\n.dot:nth-child(3) { animation-delay: 0.3s; }\n@keyframes bounce {\n  0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }\n  30% { transform: translateY(-4px); opacity: 1; }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Three identical dots share one <code>@keyframes<\/code> animation, and the only difference between them is <code>animation-delay<\/code> staggered by 0.15s each \u2014 that offset is the entire trick, turning one bounce animation into what reads as a wave moving left to right. It&#8217;s a purpose-built variant of the spinner: same &#8220;something is happening, duration unknown&#8221; message, but shaped and sized specifically to sit inside a chat bubble, which is why it&#8217;s become the near-universal convention for &#8220;the other person (or the AI) is composing a reply&#8221; across messaging products. Keeping it visually distinct from a generic spinner matters here \u2014 a spinner inside a chat bubble reads as &#8220;loading the conversation,&#8221; while three dots specifically read as &#8220;someone is actively typing right now,&#8221; a subtly different and more accurate message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> chat interfaces, AI assistant responses, and any &#8220;the other party is composing something&#8221; moment. <strong>Tip:<\/strong> keep the dot count at three \u2014 it&#8217;s long enough to read as a wave rather than a single blink, and short enough to stay compact inside a narrow chat bubble.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">10. Staggered List Reveal<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 each row fades and slides in slightly after the one above it, instead of the whole list appearing at once.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/10.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Staggered List Reveal Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>items.forEach(function (item, i) {\n  var li = buildRow(item);\n  li.classList.add('entering');   \/\/ opacity: 0, translateY(6px)\n  list.appendChild(li);\n  setTimeout(function () {\n    li.classList.remove('entering');\n    li.classList.add('entered'); \/\/ transitions to opacity: 1, translateY(0)\n  }, i * 110);\n});<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Every row is appended to the list already in its hidden &#8220;entering&#8221; state, and a <code>setTimeout<\/code> delayed by <code>i * 110<\/code> milliseconds \u2014 the row&#8217;s index times a fixed stagger interval \u2014 is what removes that class and lets its own CSS transition run. The visual effect reads as data &#8220;arriving&#8221; in sequence rather than the list simply snapping into existence, which matters most for longer feeds where an instant swap from skeleton to twenty rows at once can feel more like a jarring pop-in than a load. Because each row only needs to know its own index to compute its delay, this scales to any list length without extra bookkeeping \u2014 the loop above works identically whether there are 4 items or 40, just with a longer total reveal time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> feeds, search results, and notification lists where content arrives as a batch and a single simultaneous pop-in would feel abrupt. <strong>Tip:<\/strong> keep the per-row delay under roughly 150ms and cap the total stagger \u2014 a list of 30 items staggered at 110ms each takes over 3 seconds to fully render, which stops feeling polished and starts feeling slow.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">11. Blur-Up Image Loading<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Click &#8220;Simulate load&#8221; \u2014 a soft blurred placeholder (the image&#8217;s real colors, just heavily blurred) sharpens into the finished image instead of showing a blank box.<\/p>\n\n\n\n<iframe src=\"https:\/\/webdevpuneet.com\/demos\/a1\/20\/11.html\" style=\"width:100%;height:480px;border:0;border-radius:10px;overflow:hidden;\" loading=\"lazy\" title=\"Blur-Up Image Loading Demo by webdevpuneet.com\"><\/iframe>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How it works:<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>.ph {\n  filter: blur(18px);\n  transform: scale(1.15);   \/* hides the blurred edge softening past the box *\/\n  transition: filter 0.6s ease, transform 0.6s ease;\n}\n.ph.sharp { filter: blur(0); transform: scale(1); }<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The technique \u2014 popularized as &#8220;LQIP&#8221; (low-quality image placeholder) by sites like Medium \u2014 shows a tiny, heavily blurred version of the actual image immediately, then swaps or sharpens it into the full-resolution version once that&#8217;s downloaded. The blur is what makes a handful of pixels look intentional rather than broken; a small blurred JPEG (often under 1KB) still carries the image&#8217;s real colors and rough composition, so the placeholder is recognizably related to what&#8217;s coming instead of a generic gray box. The slight <code>scale(1.15)<\/code> is a practical detail: blurring an element also visibly softens its edges past the element&#8217;s own boundary, and scaling it up just enough hides that soft edge so it doesn&#8217;t peek past the container&#8217;s border. Real implementations generate the tiny placeholder server-side and swap the <code>&lt;img src&gt;<\/code> once the full image loads; this demo fakes the swap with a timer and a CSS class toggle to show the transition itself.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Best for:<\/strong> hero images, photo galleries, and any content-heavy page where blank white boxes during image load would be jarring. <strong>Tip:<\/strong> generate the blurred placeholder at build time or upload time, not in the browser \u2014 the whole point is that the tiny placeholder is available instantly, before the real image has downloaded at all.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Common pitfalls<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>A spinner with no timeout or failure state.<\/strong> Past 8-10 seconds a spinner stops reading as &#8220;loading&#8221; and starts reading as &#8220;frozen&#8221; \u2014 always pair it with a fallback message or retry option for requests that can genuinely fail or hang.<\/li>\n\n\n\n<li><strong>A skeleton that doesn&#8217;t match the final layout&#8217;s dimensions.<\/strong> If the placeholder is narrower, shorter, or differently proportioned than the real content, the swap causes exactly the layout jump skeletons exist to prevent \u2014 demo #8&#8217;s crossfade sidesteps this by fixing the container&#8217;s height outright.<\/li>\n\n\n\n<li><strong>No minimum display time, causing flicker on fast connections.<\/strong> If a request sometimes resolves in 80ms, showing a loading state for that long can flash on and off faster than it&#8217;s readable \u2014 a short artificial minimum (150-250ms) keeps it from looking like a glitch.<\/li>\n\n\n\n<li><strong>Missing <code>aria-live<\/code> or <code>aria-busy<\/code> for screen reader users.<\/strong> A purely visual loading state is invisible to anyone using a screen reader; <code>aria-busy=\"true\"<\/code> on the loading container and an <code>aria-live=\"polite\"<\/code> region for status changes are what carry the same information non-visually.<\/li>\n\n\n\n<li><strong>Disabling a button without visually showing why.<\/strong> A button that goes gray and unresponsive with no spinner or label change reads as broken, not busy \u2014 demo #6&#8217;s combination of disabled state, spinner, and text change is what keeps a disabled control legible.<\/li>\n\n\n\n<li><strong>Ignoring reduced-motion preferences.<\/strong> Spinners, shimmer sweeps and bouncing dots run forever, which is exactly what <code>prefers-reduced-motion: reduce<\/code> users have asked to avoid. Wrap the motion in <code>@media (prefers-reduced-motion: no-preference)<\/code>, or swap it for a slow opacity pulse or a plain &#8220;Loading\u2026&#8221; label when the user prefers less motion.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Should I use a spinner or a skeleton screen?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Reach for a skeleton (demos #2, #3, #8) whenever you know the shape of the content that&#8217;s coming \u2014 a card, a list, a profile \u2014 since it also reduces the layout shift when that content lands. Reach for a spinner (demo #1) or an indeterminate bar (demo #5) when there&#8217;s no meaningful shape to preview, like a short action with no structured result, such as a button click or a small inline confirmation.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Is there a minimum amount of time a loading state should show?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Yes, in practice \u2014 a loading state that appears and disappears in under roughly 150ms can flicker rather than communicate anything, especially on a fast connection or a cached response. A common fix is tracking the request&#8217;s start time and, if it resolves faster than a set minimum, holding the loading state visible for the remainder of that minimum before showing the result.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I make loading states accessible to screen reader users?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Mark the loading container with <code>aria-busy=\"true\"<\/code> while it&#8217;s active, and use a live region (<code>aria-live=\"polite\"<\/code>, or <code>role=\"status\"<\/code>) to announce meaningful status changes, like &#8220;Loading&#8221; and then &#8220;Loaded.&#8221; Purely decorative motion \u2014 like a spinning icon \u2014 doesn&#8217;t need its own announcement; what matters is that the start and end of the wait are communicated in text, not just conveyed visually.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What&#8217;s the actual risk with optimistic UI?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The interface shows a result before the server has confirmed it actually happened, which means every optimistic update needs a genuine rollback path for the failure case \u2014 demo #7 shows this directly, snapping back to the exact prior state and surfacing a short message when the simulated request fails. Skipping the rollback path isn&#8217;t a shortcut; it&#8217;s a bug that only shows up once real requests start failing in production.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why do some of these demos use CSS animations and others use JavaScript timers?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Whenever a loading state is pure, repeating motion with no state to track \u2014 a spinner, a shimmer sweep, a bouncing dot \u2014 CSS <code>@keyframes<\/code> alone can drive it, running smoothly without any JavaScript polling. The moment a pattern needs to know something \u2014 how much progress has actually happened, whether a request succeeded or failed, when to swap one layer for another \u2014 that&#8217;s state, and state belongs in JavaScript, with CSS classes as the hand-off point between the two.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Every demo above ships its full HTML, CSS and JS in the <strong>HTML \/ CSS \/ JS<\/strong> tabs in its own top bar. The fastest way to actually internalize this is to click <strong>Fork &amp; Edit<\/strong> on demo #7 \u2014 the optimistic UI one \u2014 and change the failure rate from 25% to 0%. Watch how much less &#8220;honest&#8221; it feels once the rollback path can never actually run; that&#8217;s the exact trade-off every optimistic interaction is making, just usually hidden from view. Once you&#8217;ve got a version you like, save it to <a href=\"https:\/\/webdevpuneet.com\/ui-snippets\/mycode\/\">My Code<\/a> and it&#8217;s yours to keep, tweak further, or drop straight into a real project.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Recommended next step:<\/strong> open the <a href=\"https:\/\/webdevpuneet.com\/css-playground\/\">CSS Playground<\/a> and build a loading state of your own from scratch \u2014 start with a spinner, then try combining a skeleton with the crossfade technique from demo #8 on a layout you&#8217;re actually working on. For more posts like this one, browse the <a href=\"https:\/\/webdevpuneet.com\/blog\/\">webdevpuneet.com blog<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A loading state is the one piece of UI almost every product ships without much thought, and the one users notice the moment it&#8217;s wrong. A spinner that never resolves reads as broken. A skeleton that doesn&#8217;t match the content it precedes causes a visible jump the instant real data lands. A button that goes [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":206,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_et_pb_use_builder":"","_et_pb_old_content":"","_et_gb_content_width":"","advanced_seo_description":"11 copy-ready loading states in HTML, CSS and JS: spinners, skeletons, shimmer, progress bars, button loading, optimistic UI, typing dots and blur-up images.","jetpack_seo_html_title":"11 Loading State Examples You Can Copy (HTML, CSS & JS)","jetpack_seo_noindex":false,"jetpack_seo_schema_type":"","_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"Need better loading states for your website?\n\nExplore 11 copy-ready HTML, CSS & JS examples, including spinners, skeleton screens, shimmer effects, progress bars, button loaders, and typing indicators.\n\nPreview live and copy the code.\n\n#WebDev #CSS #JavaScript\n","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"jetpack_post_was_ever_published":false},"categories":[16],"tags":[],"class_list":["post-205","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ui-ux","et-has-post-format-content","et_post_format-et-post-format-standard"],"jetpack_publicize_connections":[],"jetpack_sharing_enabled":true,"jetpack_likes_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/phrh5V-3j","jetpack_featured_media_url":"https:\/\/i0.wp.com\/webdevpuneet.com\/blog\/wp-content\/uploads\/2026\/10\/11-loading-state-examples-featured-1200x630-compressed.jpg?fit=1200%2C630&ssl=1","_links":{"self":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/205","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/comments?post=205"}],"version-history":[{"count":1,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/205\/revisions"}],"predecessor-version":[{"id":207,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/posts\/205\/revisions\/207"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media\/206"}],"wp:attachment":[{"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/media?parent=205"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/categories?post=205"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/webdevpuneet.com\/blog\/wp-json\/wp\/v2\/tags?post=205"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}