<?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 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>
