<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Navid jaberi</title>
    <description>The latest articles on DEV Community by Navid jaberi (@navidjaberi).</description>
    <link>https://dev.to/navidjaberi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4159380%2F9436b957-5e35-4c49-bb9c-73a8b1bb900e.jpg</url>
      <title>DEV Community: Navid jaberi</title>
      <link>https://dev.to/navidjaberi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/navidjaberi"/>
    <language>en</language>
    <item>
      <title>Why Your Loading Spinner Flashes, and the Two Numbers That Fix It in Vue</title>
      <dc:creator>Navid jaberi</dc:creator>
      <pubDate>Wed, 14 Oct 2026 08:43:55 +0000</pubDate>
      <link>https://dev.to/navidjaberi/why-your-loading-spinner-flashes-and-the-two-numbers-that-fix-it-in-vue-45ja</link>
      <guid>https://dev.to/navidjaberi/why-your-loading-spinner-flashes-and-the-two-numbers-that-fix-it-in-vue-45ja</guid>
      <description>&lt;p&gt;You click a button, the request takes 80ms, and a spinner appears for a single frame. It doesn't look fast. It looks broken.&lt;/p&gt;

&lt;p&gt;Some context first. I started &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;vue-smart-loading-kit&lt;/a&gt; because of skeletons. In my Vue projects I used Vuetify's skeleton loader, but I couldn't get the colors and animation I wanted, and its presets didn't match a lot of my real layouts. I kept hand-writing CSS to make a skeleton look like the actual component.&lt;/p&gt;

&lt;p&gt;At some point I asked myself: why not turn the real layout itself into its skeleton, with CSS only and without touching the DOM? That became &lt;code&gt;v-skeleton&lt;/code&gt;, which I wrote about in &lt;a href="https://dev.to/navidjaberi/i-built-a-vue-directive-that-turns-your-real-component-into-its-own-skeleton-3853"&gt;my previous article&lt;/a&gt;. A reader there spotted a bug with SVG icons in the comments, and I shipped the fix in 0.3.4 the same day.&lt;/p&gt;

&lt;p&gt;But a skeleton that looks right is only half of a good loading state. The other half is timing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Spinner&lt;/span&gt; &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loader follows &lt;code&gt;loading&lt;/code&gt; exactly. That's the problem: users don't care how long &lt;code&gt;loading&lt;/code&gt; was &lt;code&gt;true&lt;/code&gt;. They care about what they saw. An 80ms request shows a spinner for 80ms, which is one flash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a delay creates a new problem
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to wait a little before showing the loader. Say 200ms. Now the 80ms request shows nothing, which is what we want.&lt;/p&gt;

&lt;p&gt;But take a request that lasts 250ms. The spinner appears at 200ms and disappears at 250ms: it's on screen for 50ms. The flash is back, just later. I call that a blink.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two numbers
&lt;/h2&gt;

&lt;p&gt;When I built &lt;code&gt;SmartLoader&lt;/code&gt;, the timing came down to two numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;delay&lt;/code&gt; (200ms):&lt;/strong&gt; a load must last this long before any loader appears. Fast loads show nothing at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;minDuration&lt;/code&gt; (500ms):&lt;/strong&gt; once a loader is visible, it stays at least this long. It never just blinks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more rule: if loading starts again while the loader is still up, the loader simply stays.&lt;/p&gt;

&lt;p&gt;Here's what that does to a few loads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;The load takes&lt;/th&gt;
&lt;th&gt;What the user sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;td&gt;No loader. The content just appears.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;300ms&lt;/td&gt;
&lt;td&gt;Loader from 200ms to 700ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;Loader from 200ms to 2s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46hvqx9mnzc4b3np8grz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F46hvqx9mnzc4b3np8grz.png" alt="Timeline with a 200ms delay and 500ms minimum duration: a 120ms load shows no loader, a 300ms load shows it from 200ms to 700ms, and a 2s load shows it from 200ms to 2s" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A simplified version of the idea
&lt;/h2&gt;

&lt;p&gt;This isn't the package's code, just the core logic in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Ref&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useDelayedLoadingSimple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;minDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;delayTimer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;minTimer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;setTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;shownAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

  &lt;span class="nf"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// restarted: keep the loader if it's up&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="nx"&gt;delayTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="nx"&gt;shownAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delayTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// done before the delay: never show&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;minDuration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;shownAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nx"&gt;minTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;immediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nf"&gt;onScopeDispose&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delayTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;minTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;show&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the package this is &lt;code&gt;useDelayedLoading&lt;/code&gt;. &lt;code&gt;loading&lt;/code&gt; and both options can be refs or getters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useDelayedLoading&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue-smart-loading-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;showLoader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useDelayedLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minDuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Timing is only half of it
&lt;/h2&gt;

&lt;p&gt;A real loading state needs more than a boolean, so &lt;code&gt;SmartLoader&lt;/code&gt; wraps the content and also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keeps the content's height while the loader stands in for it, so the page below doesn't jump;&lt;/li&gt;
&lt;li&gt;sets &lt;code&gt;aria-busy="true"&lt;/code&gt; as soon as loading starts, and announces a &lt;code&gt;label&lt;/code&gt; to screen readers while the loader is visible;&lt;/li&gt;
&lt;li&gt;shows an error with a &lt;strong&gt;Try again&lt;/strong&gt; button that emits &lt;code&gt;retry&lt;/code&gt;. During a retry the error stays on screen until the loader appears, so nothing flashes in between.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;SmartLoader&lt;/span&gt; &lt;span class="na"&gt;:loading=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt; &lt;span class="na"&gt;:error=&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt; &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="na"&gt;retry=&lt;/span&gt;&lt;span class="s"&gt;"loadUsers"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;UserList&lt;/span&gt; &lt;span class="na"&gt;:users=&lt;/span&gt;&lt;span class="s"&gt;"users"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/SmartLoader&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has three modes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;While loading&lt;/th&gt;
&lt;th&gt;Good for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;replace&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;Swaps the content for a skeleton&lt;/td&gt;
&lt;td&gt;First load, no data yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;overlay&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keeps the content dimmed and inert, with a spinner on top&lt;/td&gt;
&lt;td&gt;Refreshing data, submitting forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skeletonize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Turns the content into its own skeleton with &lt;code&gt;v-skeleton&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;First load with placeholder data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Trade-offs, and what else is out there
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;minDuration&lt;/code&gt; isn't free. If a request takes 250ms, the data is ready at 250ms but the loader stays until 700ms. I think a steady half second reads better than a 50ms blink, but it's a trade-off, which is why both numbers are props you can change.&lt;/p&gt;

&lt;p&gt;The idea isn't new either. In React, &lt;a href="https://www.npmjs.com/package/spin-delay" rel="noopener noreferrer"&gt;spin-delay&lt;/a&gt; does the same thing with different defaults (500ms delay, 200ms minimum). There's no single right pair of numbers. It depends on how fast your API usually is and how heavy your loader looks.&lt;/p&gt;

&lt;p&gt;A few related tools solve other parts of the problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS &lt;code&gt;animation-delay&lt;/code&gt;&lt;/strong&gt; can hide a loader for the first 200ms, but it can't keep one on screen after &lt;code&gt;loading&lt;/code&gt; turns false, because the element is removed right away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vue's &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;&lt;/strong&gt; (still experimental) has a &lt;code&gt;timeout&lt;/code&gt; prop that delays the fallback while content is being replaced, but there's no minimum duration, and it only covers async components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping the old data on screen&lt;/strong&gt; while new data loads (stale-while-revalidate, or SmartLoader's &lt;code&gt;overlay&lt;/code&gt; mode) is often better than any loader when you're refreshing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you can avoid a loader entirely, do that. SmartLoader is for the cases where you can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If you remember one thing: don't bind a loader directly to &lt;code&gt;loading&lt;/code&gt;. Put a short delay in front of it and a minimum duration behind it.&lt;/p&gt;

&lt;p&gt;The package is on npm as &lt;code&gt;vue-smart-loading-kit&lt;/code&gt;, and the &lt;a href="https://navidjaberi.github.io/vue-smart-loading-kit/" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; lets you play with both numbers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;https://github.com/navidjaberi/vue-smart-loading-kit&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;I write about Vue and frontend on &lt;a href="https://www.linkedin.com/in/navid-jaberi" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What delay do you use for loaders in your apps, or do you show them right away?&lt;/p&gt;

</description>
      <category>vue</category>
      <category>nuxt</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built a Vue directive that turns your real component into its own skeleton</title>
      <dc:creator>Navid jaberi</dc:creator>
      <pubDate>Sat, 03 Oct 2026 09:22:13 +0000</pubDate>
      <link>https://dev.to/navidjaberi/i-built-a-vue-directive-that-turns-your-real-component-into-its-own-skeleton-3853</link>
      <guid>https://dev.to/navidjaberi/i-built-a-vue-directive-that-turns-your-real-component-into-its-own-skeleton-3853</guid>
      <description>&lt;p&gt;Skeleton loaders have a maintenance problem. You build a second version of every component by hand: a grey circle where the avatar goes, three bars where the text goes. Then someone adds a badge to the real card, and the skeleton quietly stops matching.&lt;/p&gt;

&lt;p&gt;I wanted the skeleton to come from the component itself. So I built &lt;code&gt;v-skeleton&lt;/code&gt;, a directive in &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;vue-smart-loading-kit&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;UserCard&lt;/span&gt; &lt;span class="na"&gt;v-skeleton=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt; &lt;span class="na"&gt;:user=&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While &lt;code&gt;loading&lt;/code&gt; is true, every line of text becomes a bar, images and buttons become solid blocks, and borders and spacing stay exactly as they are. When loading ends, the real content is back. &lt;a href="https://navidjaberi.github.io/vue-smart-loading-kit/" rel="noopener noreferrer"&gt;Try it in the live demo&lt;/a&gt; (Skeletonize page, tick "loading").&lt;/p&gt;

&lt;p&gt;This post is about how it works, and about the bugs that only showed up because of how I tested it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: never change the DOM
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to measure the real elements and render placeholder boxes over them. That needs JavaScript on every render, and every resize, and it fights with your layout.&lt;/p&gt;

&lt;p&gt;I took the opposite route. The directive only toggles a class, a few CSS variables and two attributes on the element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;article&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"card vslk-skeletonize vslk-skeletonize--shimmer"&lt;/span&gt;
         &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"--vslk-sk-base: …; --vslk-sk-hi: …"&lt;/span&gt;
         &lt;span class="na"&gt;inert&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else is CSS. No element is added, removed or resized, so nothing can move. That turned out to be the most important design decision, and the hardest to keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hiding text without breaking borders
&lt;/h2&gt;

&lt;p&gt;My first version hid text with &lt;code&gt;color: transparent&lt;/code&gt;. It looked right until a code review found that it also erased every border, divider and icon that uses &lt;code&gt;currentColor&lt;/code&gt;. A card with a &lt;code&gt;1px solid currentColor&lt;/code&gt; border lost its border.&lt;/p&gt;

&lt;p&gt;The fix is a property most people never touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.vslk-skeletonize&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;-webkit-text-fill-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;text-fill-color&lt;/code&gt; paints only the glyphs. &lt;code&gt;color&lt;/code&gt;, and everything that inherits it through &lt;code&gt;currentColor&lt;/code&gt;, keeps its real value.&lt;/p&gt;

&lt;h2&gt;
  
  
  One bar per line of text
&lt;/h2&gt;

&lt;p&gt;To draw bars where the lines are, I mask the element's background with a repeating gradient sized by the &lt;code&gt;lh&lt;/code&gt; unit (one line-height):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;mask-image&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;repeating-linear-gradient&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="nt"&gt;bottom&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
  &lt;span class="err"&gt;#000&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="nt"&gt;lh&lt;/span&gt; &lt;span class="nt"&gt;-&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="nt"&gt;em&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
  &lt;span class="nt"&gt;transparent&lt;/span&gt; &lt;span class="nt"&gt;calc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="nt"&gt;lh&lt;/span&gt; &lt;span class="nt"&gt;-&lt;/span&gt; &lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;3&lt;/span&gt;&lt;span class="nt"&gt;em&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="nt"&gt;lh&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each line gets a bar, with a gap between lines. A paragraph that wraps to three lines gets three bars, at whatever width the browser chose.&lt;/p&gt;

&lt;p&gt;Masking the whole element has a side effect: it also hides the element's own border, which matters for table cells. So the mask has two more layers, a border-box layer and a padding-box layer combined with &lt;code&gt;mask-composite: exclude&lt;/code&gt;. Together they leave a ring where the border is, so table grid lines survive.&lt;/p&gt;

&lt;p&gt;Images get &lt;code&gt;object-position: -99999px&lt;/code&gt;, which slides the picture out of its own box while the box keeps its size and radius. Buttons and inputs become solid blocks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving that nothing moves
&lt;/h2&gt;

&lt;p&gt;"Nothing moves" is easy to claim and hard to test. jsdom, which most Vue tests run in, has no layout engine: every element is 0×0, so any position assertion passes.&lt;/p&gt;

&lt;p&gt;So the visual rules are tested in a real Chromium with Vitest Browser Mode and Playwright. The main test records the bounding box of every element, turns the skeleton on, and records them again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;does not move or resize a single element&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rects&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;skeletonize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rects&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;before&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in CI on every push, so any CSS change that shifts a single pixel fails the build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SSR bug I almost shipped
&lt;/h2&gt;

&lt;p&gt;With server rendering and placeholder data, you want the skeleton in the server HTML, so fake content never flashes. Directives don't run on the server, but Vue has a &lt;code&gt;getSSRProps&lt;/code&gt; hook for exactly this, and Vue 3.5's &lt;code&gt;data-allow-mismatch&lt;/code&gt; lets hydration accept the attributes the client sets again.&lt;/p&gt;

&lt;p&gt;The bug was in restoring attributes. When the skeleton ends, the directive puts back whatever &lt;code&gt;inert&lt;/code&gt; and &lt;code&gt;aria-hidden&lt;/code&gt; the element had before. My first version read "before" from the DOM. After SSR, the DOM already contained the skeleton's own &lt;code&gt;inert&lt;/code&gt;, so it saved &lt;code&gt;inert&lt;/code&gt; as the original value. When loading ended, it restored it, and the whole section stayed unclickable forever.&lt;/p&gt;

&lt;p&gt;The fix was to read the original values from the vnode, which is what the template actually binds, never from the DOM. A test now server-renders, hydrates, ends the load and checks that no attribute is left behind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timing: when to show it at all
&lt;/h2&gt;

&lt;p&gt;A skeleton that flashes for 80ms on a fast request feels worse than no skeleton. So &lt;code&gt;v-skeleton&lt;/code&gt; waits 200ms before showing anything, and once shown it stays for at least 500ms so it never blinks off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- refreshing real data: keep the delay --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;UserCard&lt;/span&gt; &lt;span class="na"&gt;v-skeleton=&lt;/span&gt;&lt;span class="s"&gt;"refreshing"&lt;/span&gt; &lt;span class="na"&gt;:user=&lt;/span&gt;&lt;span class="s"&gt;"user"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- first load with placeholder data: show it from the first frame --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;ul&lt;/span&gt; &lt;span class="na"&gt;v-skeleton=&lt;/span&gt;&lt;span class="s"&gt;"{ loading, delay: 0 }"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;…&lt;span class="nt"&gt;&amp;lt;/ul&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timing tests use fake timers and record the value at every 10ms step, so a single wrong frame fails them. Checking only the final state would miss a flash in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 100% coverage missed
&lt;/h2&gt;

&lt;p&gt;The package had about 99% line coverage, and I still didn't know whether the tests would notice a broken default. So I ran &lt;a href="https://stryker-mutator.io/" rel="noopener noreferrer"&gt;Stryker&lt;/a&gt;, which plants small bugs in the source (flips a condition, changes a constant, drops a call) and reruns the tests for each one.&lt;/p&gt;

&lt;p&gt;The first score was 80%. Some of the surviving mutants were real gaps: you could change the table skeleton's default row height and no test failed. Two were real bugs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;v-skeleton&lt;/code&gt; removed an &lt;code&gt;aria-hidden&lt;/code&gt; that the template had bound to &lt;code&gt;false&lt;/code&gt;, while Vue itself renders it as &lt;code&gt;"false"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The color helper accepted &lt;code&gt;#12345z&lt;/code&gt; as a valid hex color, because &lt;code&gt;parseInt("5z", 16)&lt;/code&gt; stops at the bad digit and returns 5.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After closing the gaps the score is above 90%. The rest are mostly equivalent mutants, changes no test could observe, which is why 100% isn't the goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;It's CSS, so it has CSS's blind spots. The last line of a wrapped paragraph gets a full-width bar, because CSS can't know where the text ends. A few characters on a colored circle (an initials avatar) look like text, so you mark them &lt;code&gt;data-skeleton="block"&lt;/code&gt;. And it needs browsers from late 2023 on, for &lt;code&gt;:has()&lt;/code&gt;, &lt;code&gt;lh&lt;/code&gt; and &lt;code&gt;mask-composite&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;vue-smart-loading-kit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VueSmartLoadingKit&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue-smart-loading-kit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue-smart-loading-kit/style.css&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;VueSmartLoadingKit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kit also has a &lt;code&gt;SmartLoader&lt;/code&gt; component with an error state and retry, 13 skeleton variants, 9 spinners and progress bars.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live demo: &lt;a href="https://navidjaberi.github.io/vue-smart-loading-kit/" rel="noopener noreferrer"&gt;https://navidjaberi.github.io/vue-smart-loading-kit/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;https://github.com/navidjaberi/vue-smart-loading-kit&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd love feedback, especially on layouts where the skeleton doesn't look right.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>css</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
