I ran my Vue skeleton directive on Vuetify and Nuxt UI, and five things broke
A while ago I wrote about v-skeleton, a directive that turns a real component into its own loading skeleton: <UserCard v-skeleton="loading" :user="user" /> You don't build a second placeholder component. The dir
A while ago I wrote about v-skeleton, a directive that turns a real component into its own loading skeleton:
<UserCard v-skeleton="loading" :user="user" />
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.
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 (v-card, v-list, v-table, v-chip, UCard, UBadge, UTable, UAlert) and had a close look.
What broke
Here's the Vuetify page with real content:
And the same page with v-skeleton in 0.4.0:
At a glance it passes. Then I started counting the problems:
- The icon-font icons were gone.
mdi-accountand the chevron in the list left empty gaps where they used to be. -
v-tablegives its rows a fixed height, so a one-word cell drew two or three bars stacked on top of each other. - 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.
- The initials avatar turned into a striped circle.
Nuxt UI added a fifth:
- The two badges at the top right merged into one long bar.
<UBadge>renders a<span>, and the flex row around them is a<div>holding nothing but<span>s. My selector treats that as a line of text, so the whole row got one bar.
Why
Problems 2 to 5 come from the same place. I drew the bars like this:
/* paint the element's box, then cut it into one stripe per line */
background-color: var(--base);
mask-image: repeating-linear-gradient(
to bottom,
#000 0 calc(1lh - 0.3em),
transparent calc(1lh - 0.3em) 1lh
);
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.
I needed something the browser draws per line of text, and only as wide as the text. CSS has that already: an underline.
The fix: a very thick underline, moved up over the text
text-decoration: underline solid;
text-decoration-color: var(--base);
text-decoration-thickness: 0.9em;
text-underline-offset: -0.75em; /* pull it up over the glyphs */
text-decoration-skip-ink: none; /* keep it solid under descenders */
The glyphs are already invisible (-webkit-text-fill-color: transparent), 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.
Here's the Vuetify page with the new bars:
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.
And Nuxt UI:
Each badge keeps its pill shape and gets its own bar.
The missing icons needed a separate fix. An icon font draws its glyph in ::before, 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 <i>, or an empty element with icon 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 <span>s masked to the icon's shape.
:is(i, [class*="icon"]):empty {
background-color: var(--base);
}
Animating an underline
The old bars were backgrounds, so the shimmer was a gradient sliding across background-image. You can't put a gradient on a text decoration, so both animations needed another approach.
For shimmer, I slide a mask across the element instead. A soft band of lower opacity moves over the bars.
Pulse was harder. The rule uses !important so it can beat the page's own styles, and CSS animations can't override an !important declaration. So the bar color comes from a registered custom property, and the animation changes that property:
@property --bar-color {
syntax: "<color>";
inherits: false;
initial-value: transparent;
}
.bar {
--bar-color: var(--base);
text-decoration-color: var(--bar-color) !important;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { --bar-color: var(--highlight); }
}
The animation targets --bar-color, so the !important on text-decoration-color doesn't get in its way.
A bug I caught in the screenshots
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:
rgb(from var(--base) r g b / calc(1 - (1 - alpha) * (1 - alpha)))
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.
What still isn't perfect
- An initials avatar gets a short bar on its colored circle. If you'd rather have a solid circle, add
data-skeleton="block"to it. - Pulse on text bars needs
@property, which Firefox only has from version 128. Older browsers show the bars without the pulse. - The icon check is a guess. An empty element that draws an icon in
::beforewithouticonin its class name will still vanish.data-skeleton="block"fixes that one too.
Try it
- StackBlitz example, runs in your browser with nothing to install
- Live demo
- GitHub
- Nuxt:
npx nuxi module add nuxt-smart-loading-kit
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.



