DEV Community

Cover image for I built a Vue directive that turns your real component into its own skeleton
Navid jaberi
Navid jaberi

Posted on

I built a Vue directive that turns your real component into its own skeleton

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.

I wanted the skeleton to come from the component itself. So I built v-skeleton, a directive in vue-smart-loading-kit:

<UserCard v-skeleton="loading" :user="user" />
Enter fullscreen mode Exit fullscreen mode

While loading 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. Try it in the live demo (Skeletonize page, tick "loading").

This post is about how it works, and about the bugs that only showed up because of how I tested it.

The rule: never change the DOM

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.

I took the opposite route. The directive only toggles a class, a few CSS variables and two attributes on the element:

<article class="card vslk-skeletonize vslk-skeletonize--shimmer"
         style="--vslk-sk-base: …; --vslk-sk-hi: …"
         inert aria-hidden="true">
Enter fullscreen mode Exit fullscreen mode

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.

Hiding text without breaking borders

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

The fix is a property most people never touch:

.vslk-skeletonize * {
  -webkit-text-fill-color: transparent !important;
}
Enter fullscreen mode Exit fullscreen mode

text-fill-color paints only the glyphs. color, and everything that inherits it through currentColor, keeps its real value.

One bar per line of text

To draw bars where the lines are, I mask the element's background with a repeating gradient sized by the lh unit (one line-height):

mask-image: repeating-linear-gradient(
  to bottom,
  #000 0 calc(1lh - 0.3em),
  transparent calc(1lh - 0.3em) 1lh
);
Enter fullscreen mode Exit fullscreen mode

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.

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 mask-composite: exclude. Together they leave a ring where the border is, so table grid lines survive.

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

Proving that nothing moves

"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.

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:

it("does not move or resize a single element", async () => {
  const before = rects();
  await skeletonize();
  expect(rects()).toEqual(before);
});
Enter fullscreen mode Exit fullscreen mode

It runs in CI on every push, so any CSS change that shifts a single pixel fails the build.

The SSR bug I almost shipped

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 getSSRProps hook for exactly this, and Vue 3.5's data-allow-mismatch lets hydration accept the attributes the client sets again.

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

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.

Timing: when to show it at all

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

<!-- refreshing real data: keep the delay -->
<UserCard v-skeleton="refreshing" :user="user" />

<!-- first load with placeholder data: show it from the first frame -->
<ul v-skeleton="{ loading, delay: 0 }">…</ul>
Enter fullscreen mode Exit fullscreen mode

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.

What 100% coverage missed

The package had about 99% line coverage, and I still didn't know whether the tests would notice a broken default. So I ran Stryker, which plants small bugs in the source (flips a condition, changes a constant, drops a call) and reruns the tests for each one.

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:

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

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.

Limits

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 data-skeleton="block". And it needs browsers from late 2023 on, for :has(), lh and mask-composite.

Try it

npm install vue-smart-loading-kit
Enter fullscreen mode Exit fullscreen mode
import VueSmartLoadingKit from 'vue-smart-loading-kit'
import 'vue-smart-loading-kit/style.css'

app.use(VueSmartLoadingKit)
Enter fullscreen mode Exit fullscreen mode

The kit also has a SmartLoader component with an error state and retry, 13 skeleton variants, 9 spinners and progress bars.

I'd love feedback, especially on layouts where the skeleton doesn't look right.

Top comments (0)