<?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>I ran my Vue skeleton directive on Vuetify and Nuxt UI, and five things broke</title>
      <dc:creator>Navid jaberi</dc:creator>
      <pubDate>Wed, 04 Nov 2026 10:21:18 +0000</pubDate>
      <link>https://dev.to/navidjaberi/i-ran-my-vue-skeleton-directive-on-vuetify-and-nuxt-ui-and-five-things-broke-bm4</link>
      <guid>https://dev.to/navidjaberi/i-ran-my-vue-skeleton-directive-on-vuetify-and-nuxt-ui-and-five-things-broke-bm4</guid>
      <description>&lt;p&gt;A while ago I wrote about &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;&lt;code&gt;v-skeleton&lt;/code&gt;&lt;/a&gt;, a directive that turns a real component into its own loading skeleton:&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;You don't build a second placeholder component. The directive restyles the markup you already have with CSS: text turns into bars, images and buttons turn into blocks, and the layout stays where it was. It never touches the DOM or any sizes, so nothing on the page jumps when loading ends.&lt;/p&gt;

&lt;p&gt;I had only tested it on plain HTML fixtures I wrote myself. Most Vue apps render through a component library, though, so I put it on real Vuetify 4 and Nuxt UI 4 components (&lt;code&gt;v-card&lt;/code&gt;, &lt;code&gt;v-list&lt;/code&gt;, &lt;code&gt;v-table&lt;/code&gt;, &lt;code&gt;v-chip&lt;/code&gt;, &lt;code&gt;UCard&lt;/code&gt;, &lt;code&gt;UBadge&lt;/code&gt;, &lt;code&gt;UTable&lt;/code&gt;, &lt;code&gt;UAlert&lt;/code&gt;) and had a close look.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;Here's the Vuetify page with real content:&lt;/p&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%2Fdrbs3frm76ofah01t4jv.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%2Fdrbs3frm76ofah01t4jv.png" alt="Vuetify components with their real content" width="799" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And the same page with &lt;code&gt;v-skeleton&lt;/code&gt; in 0.4.0:&lt;/p&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%2Frya7kutm5ykq6omyqcgj.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%2Frya7kutm5ykq6omyqcgj.png" alt="Vuetify components skeletonized in 0.4.0" width="799" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At a glance it passes. Then I started counting the problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The icon-font icons were gone. &lt;code&gt;mdi-account&lt;/code&gt; and the chevron in the list left empty gaps where they used to be.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;v-table&lt;/code&gt; gives its rows a fixed height, so a one-word cell drew two or three bars stacked on top of each other.&lt;/li&gt;
&lt;li&gt;The last line of the paragraph got a full-width bar. I knew about this one and had listed it in the README as a limitation.&lt;/li&gt;
&lt;li&gt;The initials avatar turned into a striped circle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nuxt UI added a fifth:&lt;/p&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%2F8hq7rcjtj2k4amgmvdm6.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%2F8hq7rcjtj2k4amgmvdm6.png" alt="Nuxt UI components skeletonized in 0.4.0" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The two badges at the top right merged into one long bar. &lt;code&gt;&amp;lt;UBadge&amp;gt;&lt;/code&gt; renders a &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;, and the flex row around them is a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; holding nothing but &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;s. My selector treats that as a line of text, so the whole row got one bar.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Problems 2 to 5 come from the same place. I drew the bars like this:&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="c"&gt;/* paint the element's box, then cut it into one stripe per line */&lt;/span&gt;
&lt;span class="nt"&gt;background-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--base&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&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;That paints the element's whole box and slices it into stripes, one per line height. CSS has no idea where the text sits inside the box. A tall cell gets as many stripes as fit in its height. A flex row gets one stripe as wide as the row. The last line of a paragraph gets a stripe as wide as the paragraph. A small circle with two letters in it gets cut in half.&lt;/p&gt;

&lt;p&gt;I needed something the browser draws per line of text, and only as wide as the text. CSS has that already: an underline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a very thick underline, moved up over the text
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nt"&gt;text-decoration&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;underline&lt;/span&gt; &lt;span class="nt"&gt;solid&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="nt"&gt;text-decoration-color&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--base&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nt"&gt;text-decoration-thickness&lt;/span&gt;&lt;span class="o"&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;9&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;text-underline-offset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="err"&gt;75&lt;/span&gt;&lt;span class="nt"&gt;em&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* pull it up over the glyphs */&lt;/span&gt;
&lt;span class="nt"&gt;text-decoration-skip-ink&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nt"&gt;none&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="c"&gt;/* keep it solid under descenders */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The glyphs are already invisible (&lt;code&gt;-webkit-text-fill-color: transparent&lt;/code&gt;), so you only see a solid bar behind each line. The browser does the line breaking, and each bar ends where that line's text ends.&lt;/p&gt;

&lt;p&gt;Here's the Vuetify page with the new bars:&lt;/p&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%2Fvvrdak0kfb9s93djfajo.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%2Fvvrdak0kfb9s93djfajo.png" alt="The same Vuetify components skeletonized in 0.5" width="799" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The paragraph's last line is short now, and each table cell has one bar as wide as its word. The avatar shows a short bar on its blue circle, which reads better than stripes. Since the bars no longer paint the element's box, the element keeps its own background and borders. That also let me delete a mask layer I had added only to keep table borders visible.&lt;/p&gt;

&lt;p&gt;And Nuxt UI:&lt;/p&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%2F5k9s97tyqi4lk9ac5ww9.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%2F5k9s97tyqi4lk9ac5ww9.png" alt="The same Nuxt UI components skeletonized in 0.5" width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each badge keeps its pill shape and gets its own bar.&lt;/p&gt;

&lt;p&gt;The missing icons needed a separate fix. An icon font draws its glyph in &lt;code&gt;::before&lt;/code&gt;, and to CSS that glyph is text, so it disappeared along with the rest of the text. Selectors can't see what a pseudo-element contains, so I went with a heuristic: an empty &lt;code&gt;&amp;lt;i&amp;gt;&lt;/code&gt;, or an empty element with &lt;code&gt;icon&lt;/code&gt; in its class name, becomes a block the size of the icon. That catches Material Design Icons and Font Awesome, plus CSS icons such as Iconify's, which are empty &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;s masked to the icon's shape.&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="nd"&gt;:is&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;i&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;class&lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="s1"&gt;"icon"&lt;/span&gt;&lt;span class="o"&gt;])&lt;/span&gt;&lt;span class="nd"&gt;:empty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;background-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--base&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;h2&gt;
  
  
  Animating an underline
&lt;/h2&gt;

&lt;p&gt;The old bars were backgrounds, so the shimmer was a gradient sliding across &lt;code&gt;background-image&lt;/code&gt;. You can't put a gradient on a text decoration, so both animations needed another approach.&lt;/p&gt;

&lt;p&gt;For shimmer, I slide a mask across the element instead. A soft band of lower opacity moves over the bars.&lt;/p&gt;

&lt;p&gt;Pulse was harder. The rule uses &lt;code&gt;!important&lt;/code&gt; so it can beat the page's own styles, and CSS animations can't override an &lt;code&gt;!important&lt;/code&gt; declaration. So the bar color comes from a registered custom property, and the animation changes that property:&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="k"&gt;@property&lt;/span&gt; &lt;span class="l"&gt;--bar-color&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;syntax&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"&amp;lt;color&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;inherits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;initial-value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--bar-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--base&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;text-decoration-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--bar-color&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pulse&lt;/span&gt; &lt;span class="m"&gt;1.5s&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;infinite&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;pulse&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="m"&gt;50%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--bar-color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--highlight&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The animation targets &lt;code&gt;--bar-color&lt;/code&gt;, so the &lt;code&gt;!important&lt;/code&gt; on &lt;code&gt;text-decoration-color&lt;/code&gt; doesn't get in its way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A bug I caught in the screenshots
&lt;/h2&gt;

&lt;p&gt;While making the "after" screenshots for this post, I noticed the text bars looked lighter than the buttons next to them. The default base color is translucent, and with shimmer each block paints the gradient on top of its base color, so blocks show the base twice. A text bar has one layer. I gave the text bars the base composited over itself, using relative color syntax:&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;rgb&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="nt"&gt;var&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;--base&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;r&lt;/span&gt; &lt;span class="nt"&gt;g&lt;/span&gt; &lt;span class="nt"&gt;b&lt;/span&gt; &lt;span class="o"&gt;/&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;-&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;-&lt;/span&gt; &lt;span class="nt"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&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;-&lt;/span&gt; &lt;span class="nt"&gt;alpha&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I shipped that as 0.5.1, the same day as 0.5.0. Looking at before and after side by side found a bug my tests had missed, and the fix came with a new test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still isn't perfect
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An initials avatar gets a short bar on its colored circle. If you'd rather have a solid circle, add &lt;code&gt;data-skeleton="block"&lt;/code&gt; to it.&lt;/li&gt;
&lt;li&gt;Pulse on text bars needs &lt;code&gt;@property&lt;/code&gt;, which Firefox only has from version 128. Older browsers show the bars without the pulse.&lt;/li&gt;
&lt;li&gt;The icon check is a guess. An empty element that draws an icon in &lt;code&gt;::before&lt;/code&gt; without &lt;code&gt;icon&lt;/code&gt; in its class name will still vanish. &lt;code&gt;data-skeleton="block"&lt;/code&gt; fixes that one too.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://stackblitz.com/github/navidjaberi/vue-smart-loading-kit/tree/main/examples/stackblitz?file=src%2FApp.vue" rel="noopener noreferrer"&gt;StackBlitz example&lt;/a&gt;, runs in your browser with nothing to install&lt;/li&gt;
&lt;li&gt;&lt;a href="https://navidjaberi.github.io/vue-smart-loading-kit/" rel="noopener noreferrer"&gt;Live demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Nuxt: &lt;code&gt;npx nuxi module add nuxt-smart-loading-kit&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use PrimeVue, Quasar, Element Plus or another library, I'd like to hear where it looks wrong. Testing on real components is how I found these five.&lt;/p&gt;

</description>
      <category>vue</category>
      <category>nuxt</category>
      <category>css</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Vue Library Had 99% Line Coverage. Mutation Testing Found Two Bugs Anyway.</title>
      <dc:creator>Navid jaberi</dc:creator>
      <pubDate>Sun, 25 Oct 2026 12:37:01 +0000</pubDate>
      <link>https://dev.to/navidjaberi/my-vue-library-had-99-line-coverage-mutation-testing-found-two-bugs-anyway-d5b</link>
      <guid>https://dev.to/navidjaberi/my-vue-library-had-99-line-coverage-mutation-testing-found-two-bugs-anyway-d5b</guid>
      <description>&lt;p&gt;On October 2nd, &lt;a href="https://github.com/navidjaberi/vue-smart-loading-kit" rel="noopener noreferrer"&gt;vue-smart-loading-kit&lt;/a&gt; had 1,369 unit tests and 99% line coverage. That afternoon I added Stryker for mutation testing. About an hour and a half later I released 0.3.1 with fixes for two bugs my tests had missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What mutation testing checks
&lt;/h2&gt;

&lt;p&gt;Coverage tells you which lines ran during your tests. It can't tell you whether a test would fail if one of those lines were wrong.&lt;/p&gt;

&lt;p&gt;Stryker checks that part. It makes one small change to your source at a time, like flipping a condition, changing a default or removing a call, and runs your tests against each changed version. Each version is called a mutant. If a test fails, the mutant is killed. If all your tests still pass, it survived, and you've found code that no test really checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config
&lt;/h2&gt;

&lt;p&gt;I run mutation testing against the unit tests only. My real-browser tests are too slow to rerun for every mutant.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"testRunner"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vitest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"vitest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"configFile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vitest.stryker.config.ts"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mutate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"src/**/*.{ts,vue}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!src/**/*.d.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!src/**/types.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!src/**/*.types.ts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coverageAnalysis"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"perTest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignoreStatic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"incremental"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@stryker-mutator/vitest-runner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./scripts/stryker-vue-macros.mjs"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ignorers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"vue-macros"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"thresholds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"break"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vitest.stryker.config.ts&lt;/code&gt; is my unit test config with &lt;code&gt;tests/browser/**&lt;/code&gt; excluded. With &lt;code&gt;incremental&lt;/code&gt; on, Stryker saves its results to a file, and the next run only retests mutants touched by changed code or tests. In CI I cache that file between runs, so a small commit doesn't mean a full run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 1: Vitest 5 skips every test
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;@stryker-mutator/vitest-runner&lt;/code&gt; 10.0.0 and Vitest 5, every mutant survives. The report makes your tests look useless, and none of them actually ran.&lt;/p&gt;

&lt;p&gt;For each mutant, the runner picks the tests to run with a name pattern. It builds that pattern by joining suite and test names with a space. Vitest 5 joins them with &lt;code&gt;" &amp;gt; "&lt;/code&gt;, so the pattern matches nothing and Vitest skips everything.&lt;/p&gt;

&lt;p&gt;I run this script before each mutation run. It patches the join in the runner's two files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/stryker-vitest5-fix.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&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;node:fs&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;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules/@stryker-mutator/vitest-runner/dist/src&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;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;return nameParts.join(' ').trim();&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;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;return nameParts.filter(Boolean).join(' &amp;gt; ').trim();&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;for &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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test-helpers.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stryker-setup.js&lt;/span&gt;&lt;span class="dl"&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;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&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;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: collectTestName changed; check whether this workaround is still needed`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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="nx"&gt;after&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;If the runner's code changes, the script throws, so I'll find out when I can delete it. That was the state of things in early October. Check your runner version before you copy this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Workaround 2: Vue's compiler macros
&lt;/h2&gt;

&lt;p&gt;When Vue compiles a &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; component, it moves the arguments of &lt;code&gt;defineProps&lt;/code&gt;, &lt;code&gt;withDefaults&lt;/code&gt; and the other macros out of &lt;code&gt;setup()&lt;/code&gt;. A mutant in there calls Stryker's helper functions before they exist, and the component fails to compile.&lt;/p&gt;

&lt;p&gt;An ignorer plugin tells Stryker to leave those calls alone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/stryker-vue-macros.mjs&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PluginKind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;declareValuePlugin&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;@stryker-mutator/api/plugin&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;MACROS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defineProps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;withDefaults&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defineEmits&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defineModel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defineSlots&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;defineOptions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;strykerPlugins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="nf"&gt;declareValuePlugin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PluginKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ignore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vue-macros&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;shouldIgnore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isCallExpression&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;MACROS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;callee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Vue compiler macro: arguments are hoisted out of setup()&lt;/span&gt;&lt;span class="dl"&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prop defaults still have tests. The component tests cover them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: &lt;code&gt;#12345z&lt;/code&gt; parsed as a color
&lt;/h2&gt;

&lt;p&gt;The skeleton works out its highlight color from your base color, so the library has a small color parser. Here's the old hex part, shortened:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;RGBA&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&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;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&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;parseInt&lt;/code&gt; reads digits until it hits a character it can't use, then returns what it has so far:&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;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// NaN&lt;/span&gt;
&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the &lt;code&gt;NaN&lt;/code&gt; check rejected &lt;code&gt;#zz1234&lt;/code&gt; and let &lt;code&gt;#12345z&lt;/code&gt; through as &lt;code&gt;{ r: 18, g: 52, b: 5, a: 1 }&lt;/code&gt;. In a six- or eight-digit color, it only caught a bad character when it came first in its pair.&lt;/p&gt;

&lt;p&gt;In fairness to coverage, the warning was there if I had looked per file. &lt;code&gt;color.ts&lt;/code&gt; was one of my weakest files at 75% of statements, and the &lt;code&gt;return null&lt;/code&gt; lines for bad input had never run. I was looking at the 99% total.&lt;/p&gt;

&lt;p&gt;The fix checks the whole string up front:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, &lt;code&gt;parseInt&lt;/code&gt; can't return &lt;code&gt;NaN&lt;/code&gt; here, so I deleted the old check. A mutant inside a condition that can never be true has no test that could kill it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: &lt;code&gt;aria-hidden="false"&lt;/code&gt; disappeared after loading
&lt;/h2&gt;

&lt;p&gt;This one was in a file with 100% line coverage.&lt;/p&gt;

&lt;p&gt;While &lt;code&gt;v-skeleton&lt;/code&gt; is showing, it sets &lt;code&gt;inert&lt;/code&gt;, &lt;code&gt;aria-hidden&lt;/code&gt; and &lt;code&gt;aria-busy&lt;/code&gt; on your element. When loading ends, it puts back what your template bound. The old code treated &lt;code&gt;false&lt;/code&gt; as "remove the attribute":&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vue 3 doesn't remove an &lt;code&gt;aria-*&lt;/code&gt; attribute bound to &lt;code&gt;false&lt;/code&gt;. It renders the string &lt;code&gt;"false"&lt;/code&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;section&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;:aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  ...
&lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before loading, that element had &lt;code&gt;aria-hidden="false"&lt;/code&gt;. After loading, the attribute was gone. The line with &lt;code&gt;v === false&lt;/code&gt; ran in my tests, but no test bound an aria attribute to &lt;code&gt;false&lt;/code&gt; and checked what came back.&lt;/p&gt;

&lt;p&gt;The fix copies Vue's behavior. &lt;code&gt;inert&lt;/code&gt; is the exception, because Vue sets it as a DOM property instead of an attribute:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&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;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boolean&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run the tests from that fix against the old code, both fail with exactly these values: the parser returns &lt;code&gt;{ r: 18, g: 52, b: 5, a: 1 }&lt;/code&gt; for &lt;code&gt;#12345z&lt;/code&gt;, and &lt;code&gt;aria-hidden&lt;/code&gt; comes back as &lt;code&gt;null&lt;/code&gt; instead of &lt;code&gt;"false"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  After the fixes
&lt;/h2&gt;

&lt;p&gt;The mutation score is over 90%. It's a live badge in the README, and the full report is published with the demo, so you can see which mutants still survive. I also raised the coverage thresholds in CI the same day, to 98% of statements and 99% of lines.&lt;/p&gt;

&lt;p&gt;The score doesn't fail the build (&lt;code&gt;break: null&lt;/code&gt;). Some mutants don't change what the code does at all, and no test can kill those.&lt;/p&gt;

&lt;p&gt;For a library this size, the setup was one config file and two short scripts, and it found real bugs on the first afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you try it
&lt;/h2&gt;

&lt;p&gt;Read the list of surviving mutants before you look at the score. For me, the survivors pointed at files that the total coverage number made look fine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;code&gt;vue-smart-loading-kit&lt;/code&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;li&gt;Mutation report: &lt;a href="https://navidjaberi.github.io/vue-smart-loading-kit/mutation/" rel="noopener noreferrer"&gt;https://navidjaberi.github.io/vue-smart-loading-kit/mutation/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;More about v-skeleton: &lt;a href="https://dev.to/navidjaberi/i-built-a-vue-directive-that-turns-your-real-component-into-its-own-skeleton-3853"&gt;I built a Vue directive that turns your real component into its own skeleton&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;I also 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;If you've tried mutation testing on a Vue or React project, what did the survivors show you?&lt;/p&gt;

</description>
      <category>vue</category>
      <category>testing</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <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>
