UI · Vue 3

@alikhalilll/a-ghost-skeleton

A skeleton that measures itself from your real component. One template, pixel-aligned chrome, no second DOM tree to maintain.

Every grey rectangle above was measured from the component rendered beneath it. There is no parallel placeholder file. Padding changes on the real card and the skeleton follows; text wraps differently and the bars re-flow; a card gains a shadow and the outline copies it.

What this package is — and isn't

AGhostSkeleton owns a loading state. It is not a hand-authored shape library (<RectBone /> / <CircleBone />), and it does nothing useful without a real component plus a representative sample to measure. If you want to draw an arbitrary pill or bar with no measurement, that's <AGhostSkeletonBox> — covered below.

Install

$pnpmadd@alikhalilll/a-ghost-skeleton

Nuxt 3 / 4

Register the module. The four components auto-import, the stylesheet is injected, and the pre-paint boot script is wired for you.

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@alikhalilll/a-ghost-skeleton/nuxt'],
});

Vue 3 + Vite

Import the stylesheet once in your entry. Optionally wire the resolver so unplugin-vue-components auto-imports the four components the first time you use them.

ts
// main.ts
import '@alikhalilll/a-ghost-skeleton/styles.css';
ts
// vite.config.ts
import Components from 'unplugin-vue-components/vite';
import AGhostSkeletonResolver from '@alikhalilll/a-ghost-skeleton/resolver';

export default {
  plugins: [Components({ resolvers: [AGhostSkeletonResolver()] })],
};

Four components ship

<AGhostSkeleton> (the measured loader), <AGhostSkeletonBox> (a standalone bar), <AGhostSkeletonSuspense> (a <Suspense> wrapper that measures its fallback), and <AGhostSkeletonProvider> (app-wide theme + reveal defaults).

Why this exists

A hand-authored <ProductCardSkeleton> next to <ProductCard> is a second template that drifts: padding diverges, fonts change, images start loading later and the reveal jumps.

AGhostSkeleton sidesteps that by laying the real component out invisibly, reading the DOM it produced with getBoundingClientRect() and Canvas 2D font metrics, then reconstructing a placeholder from what it saw. One template — the real one — stays the source of truth.

  • Zero layout shift. Bone positions come from the actual DOM, not a copy.
  • Painted chrome is reproduced, not re-drawn. Backgrounds, borders, radii, and box-shadows copy over verbatim onto chrome bones.
  • Text follows the font's ascender / descender, not a guessed line-height.
  • One document clock drives every shimmer, so skeletons mounted at different times still sweep in phase.
  • Direction-aware animations. The shimmer, the content-reveal sweep, and the byPosition sequence order all follow the inherited CSS direction — an RTL page flows right-to-left with no prop or custom property.
  • One component, four states — loading, content, empty, error — plus the reveal transition between them.

Quick start

Wrap a component with <AGhostSkeleton>. Pass the data ref and a representative sample. While data is null, the plugin renders the sample out of sight, measures it, and paints the skeleton. When data arrives, it reveals.

Five rules keep the measurement honest and the paint on-brand. Each one has a short section below with a tiny before/after; the full example at the end puts them together.

Rule 1 — Always pass a sample

The sample is the shape the plugin measures while real data is loading. Its structure should mirror the real response: same number of lines of text, same avatar size, same list length. Skip it and there's nothing to measure, so the skeleton has no shape to draw.

vue
<!-- ❌ no sample — the skeleton has nothing to measure -->
<AGhostSkeleton :data="user">
  <template #default="{ data }"><UserCard :user="data" /></template>
</AGhostSkeleton>

<!-- ✅ sample matches real-response shape -->
<AGhostSkeleton :data="user" :sample="SAMPLE_USER">
  <template #default="{ data }"><UserCard :user="data" /></template>
</AGhostSkeleton>

Rule 2 — Give the skeleton a stable id

Shape memory is keyed by id across visits. A returning user sees last session's measured shape immediately — no fresh measurement delay, no generic fallback. Omit the id and every mount is a cold start.

vue
<!-- ❌ no id — cold measurement every mount -->
<AGhostSkeleton :data="user" :sample="SAMPLE_USER">…</AGhostSkeleton>

<!-- ✅ stable id — shape is recalled across visits -->
<AGhostSkeleton id="user-card" :data="user" :sample="SAMPLE_USER">…</AGhostSkeleton>

Rule 3 — Destructure { data } in #default

The plugin invokes the slot with { data: SAMPLE } during measurement. If the slot ignores that argument and reads an outer ref instead, that ref is still null while loading — children receive empty data, the ghost captures a half-drawn shape, and the cold-load skeleton is wrong. Warm visits hide the bug because the cache was written after real data arrived. See Thread data through the slot for the full walkthrough.

vue
<!-- ❌ reads the outer ref → rows=[] during measurement -->
<template #default>
  <PagesTable :rows="content?.rows ?? []" />
</template>

<!-- ✅ destructures the slot prop → rows=SAMPLE.rows during measurement -->
<template #default="{ data }">
  <PagesTable :rows="data?.rows ?? []" />
</template>

Rule 4 — Give images explicit dimensions

An <img> without width / height or aspect-ratio reports zero height until the pixels arrive. The skeleton measures that zero and reserves no space, so the reveal jumps when the image finally loads.

vue
<!-- ❌ no dimensions — reserved height is 0 until load -->
<img :src="user.avatar" />

<!-- ✅ aspect-ratio pins the reserved space -->
<img :src="user.avatar" class="aspect-square w-16" />

Rule 5 — Theme to your design with <AGhostSkeletonProvider>

The raw fallback greys (rgb(128 128 128 / 0.2) base, rgb(255 255 255 / 0.12) highlight) are translucent so they read on both light and dark pages — they're a stand-in, not a production look. Wrap your app once in <AGhostSkeletonProvider> to set the bone fill and shimmer colour against your real surface, and the provider's mode="system" toggle keeps the theme in step with prefers-color-scheme. Per-skeleton overrides work too: any ancestor can declare the --ask-* variables and the cascade carries them down.

vue
<!-- ❌ no provider — bones use the fallback greys regardless of your design -->
<AGhostSkeleton :data="user" :sample="SAMPLE_USER">…</AGhostSkeleton>

<!-- ✅ app-wide provider, light and dark themed against real surfaces -->
<AGhostSkeletonProvider
  mode="system"
  :theme="{
    light: { base: '#d1d4dc', highlight: '#ffffff' },
    dark: { base: '#31363f', highlight: 'rgba(255,255,255,0.08)' },
    radius: '6px',
  }"
>
  <AGhostSkeleton :data="user" :sample="SAMPLE_USER">…</AGhostSkeleton>
</AGhostSkeletonProvider>

See Theming for the full token table and chrome-bone rules.

Putting it together

All five rules applied — provider wraps the app with themed bones, each skeleton has a stable id, a matching sample, { data } destructured in the slot, and the avatar's dimensions pinned via the class on <UserCard>'s own <img>:

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';

const user = ref<User | null>(null);

const SAMPLE_USER = {
  name: 'Sarah Connor',
  role: 'Chief Mitigation Officer',
  avatar: 'https://example.com/avatar.jpg',
};

onMounted(async () => {
  user.value = await $fetch('/api/me');
});
</script>

<template>
  <AGhostSkeletonProvider
    mode="system"
    :theme="{
      light: { base: '#d1d4dc', highlight: '#ffffff' },
      dark: { base: '#31363f', highlight: 'rgba(255,255,255,0.08)' },
      radius: '6px',
    }"
  >
    <AGhostSkeleton id="user-card" :data="user" :sample="SAMPLE_USER">
      <template #default="{ data }">
        <UserCard :user="data" />
      </template>
    </AGhostSkeleton>
  </AGhostSkeletonProvider>
</template>

A product card driven by :data + :sample:

State machine

One component resolves four mutually-exclusive states. Resolution order is loading → error → empty → content:

vue
<AGhostSkeleton
  :data="posts"
  :sample="SAMPLE_POSTS"
  :error="fetchError"
  :empty="posts?.length === 0"
  @reveal="onContentRevealed"
>
  <template #default="{ data }">
    <PostList :posts="data" />
  </template>
  <template #empty>
    <EmptyIllustration message="No posts yet" />
  </template>
  <template #error="{ error }">
    <ErrorBanner :error="error" @retry="reload" />
  </template>
</AGhostSkeleton>

When does the skeleton activate?

Only when :data is passed as a bound prop and its value is null / undefined, or when loading is explicitly true. Passing nothing (no :data prop at all) treats the slot as always-rendered content — the escape hatch is loading.

Prefer `data` + `sample` over `loading`

The loading prop is an escape hatch for state you can't model as "data is null". Default to binding :data and let the skeleton auto-activate — you get shape memory, the ghost measurement, and the reveal transitions for free.

@reveal fires once per skeleton → content transition. It does not fire on error / empty transitions or on subsequent re-renders.

Reveal transitions

The reveal prop picks how the skeleton gives way to real content. Set it on <AGhostSkeleton> to override one skeleton, or on <AGhostSkeletonProvider> as the app-wide default.

ModeBehaviour
smartInstant if content lands under ~150 ms, otherwise a crossfade. The default.
togetherA single crossfade. Recommended for lists — per-piece staggers read as noise there.
sequenceTop-to-bottom cascade across pieces.
sweepA highlight beam traverses the transition edge. Reads best on wide cards.
instantSnap with no crossfade. The height ease still runs.

Every option above the "Motion" control in the playground below is wired to the provider in real time. The "Mode" row tracks the docs' own theme toggle, so flipping the site to dark re-tints the entire demo — including the chrome bones.

::alert{type="note" title="reveal=\"together\" on lists"} The default smart picks byRow for lists with more than a few pieces, which stacks a per-card cascade on top of the pane fade and the height ease — three animations fighting each other. together collapses the list entry into one crossfade that lands with the resize. ::

Custom reveals

For one-off needs, reveal also accepts a RevealSpec object: { order, enter, wipe, delay, duration, hold, choose }. The built-in modes are exported as helpers (together, sequence, smart, sweep, instant, byRow, byPosition, byContent, quickly) if you want to compose on top of them — see Timing & helpers.

Layout hints

The DOM walker is accurate by default. Three attributes let you override its decisions where it matters.

Hintdata-* aliasEffect
ask-blockdata-skeleton-blockDraws the element as one bone sized to its box + border-radius. The walker does not recurse. Use for action buttons where the icon + count should read as one pill.
ask-ignoredata-skeleton-ignoreKeeps the layout footprint but emits zero bones. Use for decorative dots, badges, or teleported modal shells that shouldn't appear in the ghost tree.
ask-owndata-skeleton-ownThe subtree opts out of measurement — you're drawing its own skeleton. The walker stops at this boundary.
vue
<article>
  <Header />

  <!-- icon + count collapse to a single pill bone -->
  <button type="button" data-ask-block class="rounded-full">
    <HeartIcon /> {{ count }}
  </button>

  <!-- decorative dot: leaves the layout slot, no bone -->
  <span data-ask-ignore class="status-dot" />
</article>

`ask-own` + `#skeleton` slot

If you pass your own skeleton via the #skeleton slot on <AGhostSkeleton>, measurement is skipped entirely — no need to annotate it with ask-own. ask-own is only for carving a hand-authored island inside a measured tree.

Layout Hint:
Each tag pill gets an individual measured bone.
Loading author card

Resizing between skeleton and content

A skeleton drawn from sample data or remembered shape is a guess. When the real data arrives with a different body length, image count, or line wrap, the frame around the swap doesn't snap — it eases from the outgoing pane's measured box to the incoming pane's live box.

  • RESIZE_DURATION = 260 ms
  • REVEAL_EASING = cubic-bezier(0.32, 0.72, 0, 1) (Apple's SF Pro curve)

A card that goes from 200 px to 600 px, or the other way, never jerks the viewer's eye.

Resizing:data="note" :sample="SAMPLE"
load:

Watch the card frame: the height eases to the new body length instead of snapping, even when short swaps for tall.

Timings at a glance

ConstantDefaultWhat it drives
FADE_DURATION260 msContent pane fade-in — ends on the same frame as the resize.
SKELETON_FADE_DURATION160 msSkeleton pane fade-out — shorter so bones clear while the frame is still settling.
RESIZE_DURATION260 msHeight ease on the [ask-reveal] container and the outgoing pane's lockstep shrink.
REVEAL_EASINGcubic-bezier(0.32, 0.72, 0, 1)Shared curve for every reveal animation.

All four are exported from @alikhalilll/a-ghost-skeleton so a consumer can match their own animations to the skeleton's rhythm.

How the resize is wired

  1. The outgoing pane is taken out of flow (position: absolute at its measured box) via fadeOut().
  2. The parent momentarily lays out at the incoming pane's natural height — that reading is the resize target.
  3. A Web Animation on the parent's height interpolates from the outgoing box's height to the incoming pane's live height. overflow: clip, will-change: height, and contain: layout are applied inline so the browser promotes the element onto its own compositor layer.
  4. In lockstep, when the outgoing pane was taller than the incoming one, a second animation runs on the outgoing pane's own height with the same duration and easing, clipping its bones so nothing is painted below where the real content settles.
  5. The skeleton's fade (SKELETON_FADE_DURATION, 160 ms) finishes before the resize settles — the viewer's eye isn't fighting a fading skeleton on top of content that's already laid out.

`easeHeight()` for custom code paths

ts
import { easeHeight } from '@alikhalilll/a-ghost-skeleton';

const from = panel.getBoundingClientRect().height;
panel.style.height = '480px';
easeHeight(panel, from, 480);

The height ease runs with prefers-reduced-motion: reduce honoured automatically — reduced-motion visitors see a snap.

Synchronized shimmer

Six cards, each mounted a beat after the last, every bone sharing the same document-clock sweep. Nothing resets, nothing pops in out of phase. The same pattern scales to paginated feeds, async grid cells, staggered data fetches.

Multi-card shimmer syncmount stagger: 180 ms

Six cards, mounted 180 ms apart. Every bone shares the same document-clock sweep — nothing resets, nothing pops in out of phase.

Direction-aware animations

Direction-aware shimmerdir="ltr" vs dir="rtl"
dir="ltr"reads left-to-right
dir="rtl"reads right-to-left

Same skeleton, same document clock, same provider. The only difference is the dir on the wrapper — and the shimmer sweep flips to match.

Every directional animation in the skeleton reads the inherited CSS direction and flips to match. Set <html dir="rtl"> once — or scope it with style="direction: rtl" on any ancestor — and three things flip together:

  • Shimmer — the sweep across each bone travels right-to-left instead of left-to-right. Every bone in a group stays in phase; the shared stream is preserved.
  • Sweep reveal — the content-wipe front enters from the right edge and crosses to the left, with its trailing gloss band staying behind the front in motion direction.
  • sequence cascade — the horizontal component of byPosition reads right-to-left, so a top-to-bottom cascade tilts in reading order rather than always running toward the right.

There is no prop, no new CSS variable, and no provider setting. The reason: the browser already owns writing direction through direction and dir, every other UI library you use is reading the same signal, and adding a parallel knob would just drift from it.

html
<!-- An entire page in RTL: shimmer, sweep, and sequence cascade all follow. -->
<html dir="rtl">
  <body>
    <AGhostSkeleton :data="profile" :sample="SAMPLE_PROFILE">
      <ProfileCard :profile="profile" />
    </AGhostSkeleton>
  </body>
</html>
vue
<!-- Or scope it to one subtree, e.g. a localised comment thread. -->
<section dir="rtl">
  <AGhostSkeleton :data="thread" :sample="SAMPLE_THREAD">
    <CommentThread :thread="thread" />
  </AGhostSkeleton>
</section>

What stays unchanged

pulse is an opacity fade — non-directional, so it looks identical in LTR and RTL. The content fades, the height-ease between panes, and the byRow sequence order are purely vertical or non-spatial, so they don't flip. The gradient's 20° slant is kept as-is in RTL (the motion direction flips, the band's visual slant doesn't) because mirroring it crosses from "noticeable" into "sub-threshold" at that angle and would double the geometry the sweep has to reason about.

Chrome capture

When the walker crosses a painted container — anything with a background, a border, or a box-shadow — it emits an extra chrome bone that reproduces that paint verbatim. The bone carries the captured background, per-side border-*, border-radius, and box-shadow inline, and skips the shimmer animation so it reads as an outline, not a placeholder fill.

This is why cards look right from the first frame: a border-t footer divider survives as a hairline, and the card's radius is preserved even when the outer wrapper clips it.

What chrome capture tracks:

  • backgroundColor and gradient backgroundImage layers
  • Four border sides captured separately — a one-sided border reads as a divider, not a box
  • border-radius, per-corner when the parent's overflow clips it
  • box-shadow

Chrome bones ignore --ask-base (they aren't placeholders) and carry [ask-rect="chrome"] so stylesheets can target them separately.

Theming

Everything is driven by CSS custom properties. A single declaration anywhere above the skeleton re-tints every bone inside.

css
.my-feed {
  --ask-base: #d1d4dc;
  --ask-highlight: #ffffff;
  --ask-radius: 6px;
}

For app-wide defaults, wrap your app in <AGhostSkeletonProvider>. The provider supports mode="system" which reads prefers-color-scheme:

vue
<AGhostSkeletonProvider
  mode="system"
  :theme="{
    light: { base: '#d1d4dc', highlight: '#ffffff' },
    dark: { base: '#31363f', highlight: 'rgba(255,255,255,0.08)' },
    radius: '6px',
    animation: 'shimmer',
    speed: 'slow',
  }"
  reveal="smart"
>
  <App />
</AGhostSkeletonProvider>

Light and dark are the same component tree

The provider doesn't fork its slot into two renders. It flips the --ask-* variables based on mode and lets the cascade do the rest — which is why chrome bones also re-tint when you toggle the site theme, because they inherit from <article>'s computed background-color at measurement time.

Chrome bones ≠ bone fill

--ask-base re-tints placeholder bones only. A card's outline keeps whatever colour the real <article> had — that's chrome capture working as intended, reproducing real paint rather than re-colouring it.

CSS variables

VariableDefaultDescription
--ask-basergb(128 128 128 / 0.2)Fill of placeholder bones. Chrome bones ignore this.
--ask-highlightrgb(255 255 255 / 0.12)Shimmer sweep colour.
--ask-radius0px (text bars pill by default)Border-radius on text bars and <AGhostSkeletonBox>.
--ask-animationshimmershimmer / group-shimmer / pulse / none.
--ask-speedslowslow / fast.
--ask-text-topascenderWhich font line each text bar starts at.
--ask-text-bottomdescenderWhich font line each text bar ends at.

Two layers of defaults

Without a provider, the raw CSS fallbacks are the translucent greys shown above — they read on both light and dark pages. Wrap in <AGhostSkeletonProvider> and the provider's own DEFAULT_COLORS (light rgb(0 0 0 / 0.07), dark rgb(255 255 255 / 0.08)) win, keyed off mode.

Per-type bone styling

Bones ship as <div [ask-rect="text|media|box|chrome"]> in the light DOM (no shadow root), so your stylesheet reaches them without :deep(). A 4-px minimum radius is applied to every non-chrome bone so nothing reads as a sharp glyph.

css
/* Soft inset ring on placeholder bones. */
[ask-rect='text'],
[ask-rect='box'] {
  box-shadow: inset 0 0 0 1px rgb(169 175 188 / 0.35);
}
[ask-rect] valueEmitted by
textText-node per-line bars via Range.getClientRects()
media<img> / <svg> / <video> / <canvas> / <picture> / <iframe>
box<button> / <input> / painted leaf elements / anything with data-ask-block
chromePainted or divider-bordered container — carries captured background / border-* / box-shadow inline

<AGhostSkeletonBox> — standalone bars {#standalone-box}

Use <AGhostSkeletonBox> when you need an explicit placeholder outside the measurement path — a loading indicator that doesn't wrap a component, an inline progress strip, or a composite skeleton you're hand-authoring.

vue
<div class="flex items-center gap-3">
  <AGhostSkeletonBox width="40px" height="40px" radius="999px" />
  <div class="flex-1 flex flex-col gap-1.5">
    <AGhostSkeletonBox width="60%" height="1rem" radius="4px" />
    <AGhostSkeletonBox width="40%" height="0.75rem" radius="4px" />
  </div>
</div>

It shares the document-clock shimmer, respects the provider, and honours the --ask-* variables.

<AGhostSkeletonSuspense> — for Suspense boundaries

A thin wrapper around Vue's <Suspense> that measures its fallback like any other <AGhostSkeleton>. Use it when your loading surface is driven by an async setup() / top-level await rather than a null → value transition.

vue
<AGhostSkeletonSuspense id="profile" label="Loading profile">
  <template #default>
    <AsyncProfile />
  </template>
  <template #skeleton>
    <ProfilePlaceholder />
  </template>
</AGhostSkeletonSuspense>

Accessibility

  • aria-busy="true" is set on the outer wrapper while loading.
  • The label prop renders as visually hidden text inside an [ask-status] element — screen readers hear label instead of silence.
  • Skeleton panes are aria-hidden="true" and inert, so assistive tech doesn't try to announce empty shapes.
  • (prefers-reduced-motion: reduce) disables the shimmer and collapses the reveal to instant.
  • The inherited CSS direction is read at runtime, so a <html dir="rtl"> page (or any dir-scoped subtree) flips the shimmer sweep, the content reveal, and the sequence cascade without any extra wiring — the skeleton reads the same writing-direction signal as the rest of the page.
vue
<AGhostSkeleton :data="profile" :sample="SAMPLE_PROFILE" label="Loading your profile">
  ...
</AGhostSkeleton>

Debugging a wrong-looking skeleton

If a measured skeleton doesn't line up with the real card — bones in the wrong places, padding missing, cards too narrow — turn on the built-in trace. The plugin ships a traceMeasurement() logger that fires once per element the walker touches, so you can see exactly what the DOM looked like at measurement time.

Enable it

Any one of these flips the plugin into debug mode:

  • Add ?ask-debug to the URL.
  • Run localStorage.setItem('a-ghost-skeleton:debug', '1') in DevTools, then reload.
  • Call setDebug(true) once from your app code.
  • Add an ask-debug attribute to any ancestor of the <AGhostSkeleton> you're investigating (scopes the trace to that subtree).

Reading the trace

Open DevTools and trigger a skeleton render (cold refresh, sort change, anything that puts :data back to null). The console fills with lines like:

text
[a-ghost-skeleton] <header> 456×60  padding:4px 20px 16px 20px
{ class: 'flex gap-3 items-start px-5 pb-4 pt-4', element: <header…> }

[a-ghost-skeleton] <div> 420×38  padding:0px 0px 0px 0px  · ⚠ 0-padding
{ class: 'flex flex-col', element: <div…> }

Each line is one element's measured box with its class list, computed padding, and width × height. The ⚠ 0-padding marker appears on containers that have children but no inner padding — the common signal that something went wrong between what you wrote and what the browser rendered.

Pick the element you care about (usually one that should have padding but doesn't) and check three things:

What you seeWhat it meansFix
Expected class in class: and padding is non-zeroEverything's fine. The visual problem is elsewhere (CSS specificity, parent overflow, a wrapper collapsing margins).Look upstream.
Expected class in class: but padding is 0px 0px…The class reached the DOM but your CSS didn't emit the rule. Classic UnoCSS / Tailwind lazy-scan miss — the utility isn't in the output bundle.Add the utility to your scanner's safelist, or widen the source globs so the component is covered.
Expected class not in class:Vue dropped it. Happens when a child component has a true fragment root (multiple top-level elements). Vue can't auto-forward the class into a fragment.Collapse to a single root with :class branching, or defineOptions({ inheritAttrs: false }) + manual.
class: is correct, padding is right in the real card, wrong only in the skeleton traceThe ghost tree rendered before an async style settled (fonts, lazy CSS modules).Preload the font / stylesheet, or set an aspect-ratio so the first-paint measurement is correct.

Timing & helpers

Imported from @alikhalilll/a-ghost-skeleton (or /core):

ExportSignature / valueWhat it does
FADE_DURATION260Content pane fade length.
SKELETON_FADE_DURATION160Skeleton pane fade length (shorter — bones clear before the frame settles).
RESIZE_DURATION260Height ease length.
REVEAL_EASINGcubic-bezier(0.32, 0.72, 0, 1)Shared curve for every reveal animation.
SKELETON_DELAY150Minimum time a skeleton must be visible before smart swaps to a crossfade.
easeHeight(root, from, to)—Animate any element's height along the canonical curve. Reused internally by every pane swap.
prefersReducedMotion()booleanCurrent reduced-motion preference.
forgetShapes(id?)—Clear remembered shapes from localStorage. Omit id to wipe all.
setDebug(on: boolean)—Switch debug mode on or off globally.
traceMeasurement(el, …)—Logs one element's class list, computed padding, and bounds. Called by the measure walker.
debugging(el?)booleantrue when the global flag is set, or when el has an [ask-debug] ancestor.

Recipes

Six focused examples, derived from one realistic page — an analytics overview with a metrics row, a comparison chart, a distribution list, and a paginated table. They compose: stack all six and you have the full page. Pick them apart and each one teaches a single pattern.

Nothing below is tied to a framework, chart library, or design system. Replace <DataTable>, <AreaChart>, and the class names with whatever you use.

1. One measured tile

The smallest useful loader. One card, one data object, one measured skeleton.

vue
<script setup lang="ts">
import { ref, onMounted } from 'vue';

interface Metric {
  label: string;
  value: number;
  delta: number;
}

const metric = ref<Metric | null>(null);
const SAMPLE: Metric = { label: 'Visitors', value: 12_480, delta: 0.143 };

onMounted(async () => {
  metric.value = await $fetch('/api/metrics/visitors');
});
</script>

<template>
  <AGhostSkeleton id="metric-visitors" :data="metric" :sample="SAMPLE">
    <template #default="{ data }">
      <article class="card">
        <p class="label">{{ data.label }}</p>
        <p class="value">{{ data.value.toLocaleString() }}</p>
        <p class="delta">{{ (data.delta * 100).toFixed(1) }}%</p>
      </article>
    </template>
  </AGhostSkeleton>
</template>

The three rules in one place: a stable id, a sample that mirrors the real response, a slot that destructures { data } so the sample reaches the card during measurement.

2. A row of tiles that reveal together

Wrap the whole grid in one skeleton so four cards fade in as a single unit instead of a stagger.

vue
<script setup lang="ts">
import { ref } from 'vue';

interface Overview {
  metrics: Metric[];
}

const overview = ref<Overview | null>(null);
const SAMPLE: Overview = {
  metrics: [
    { label: 'Visitors', value: 12_480, delta: 0.143 },
    { label: 'Sessions', value: 18_206, delta: 0.101 },
    { label: 'Page views', value: 41_372, delta: -0.06 },
    { label: 'Engagement', value: 64.2, delta: 0.024 },
  ],
};
</script>

<template>
  <AGhostSkeleton id="metrics-row" :data="overview" :sample="SAMPLE" reveal="together">
    <template #default="{ data }">
      <div class="grid grid-cols-4 gap-4">
        <article v-for="m in data.metrics" :key="m.label" class="card">
          <p>{{ m.label }}</p>
          <p>{{ m.value.toLocaleString() }}</p>
        </article>
      </div>
    </template>
  </AGhostSkeleton>
</template>

Default reveal="smart" picks a per-card stagger for lists, which reads as noise on a tight grid. together collapses four crossfades into one.

3. A comparison line chart

Charts render out of SVG primitives that don't measure cleanly. Use the #skeleton slot to opt out of measurement entirely, keep the chart under <ClientOnly>, and reach for lineDashArray to distinguish overlaid series.

vue
<script setup lang="ts">
import { ref } from 'vue';

interface Point {
  date: string;
  current: number;
  previous: number;
}

const series = ref<Point[] | null>(null);

// First series solid, second series dashed — lineDashArray[i] applies per series.
const lineDashArray = [[], [4, 4]];
</script>

<template>
  <AGhostSkeleton id="comparison-chart" :data="series" :sample="[]">
    <template #default>
      <ClientOnly>
        <AreaChart
          :data="series ?? []"
          :line-dash-array="lineDashArray"
          :height="288"
          :curve-type="'monotoneX'"
        />
      </ClientOnly>
    </template>

    <template #skeleton>
      <!-- Hand-authored placeholder opts out of measurement. Size it to
           match the real chart so the reveal doesn't shift. -->
      <div class="h-72" ask-block>
        <AGhostSkeletonBox width="100%" height="100%" radius="12px" />
      </div>
    </template>
  </AGhostSkeleton>
</template>

lineDashArray: [[], [4, 4]] draws series 0 solid and series 1 dashed — the shape a "this period vs previous period" overlay wants. The #skeleton slot sets ask-own for you so the walker stops at the chart root.

4. A paginated table

The slot binding is doing the work here: the sample's rows reach the child table so the measurement captures a full table shape, not an empty single-column shell. Every column <DataTable> renders gets a bone.

vue
<script setup lang="ts">
import { ref } from 'vue';

interface Page {
  url: string;
  sessions: number;
  bounce: number;
}
interface Report {
  rows: Page[];
  total: number;
}

const report = ref<Report | null>(null);
const SAMPLE: Report = {
  total: 5,
  rows: [
    { url: '/', sessions: 8420, bounce: 0.42 },
    { url: '/pricing', sessions: 3420, bounce: 0.51 },
    { url: '/docs', sessions: 2340, bounce: 0.38 },
    { url: '/blog', sessions: 1730, bounce: 0.62 },
    { url: '/about', sessions: 1214, bounce: 0.58 },
  ],
};
</script>

<template>
  <AGhostSkeleton id="pages-report" :data="report" :sample="SAMPLE">
    <template #default="{ data }">
      <DataTable :rows="data?.rows ?? []" :total="data?.total ?? 0" />
    </template>
  </AGhostSkeleton>
</template>

The same markup without the destructure measures an empty table — see Thread data through the slot for the before / after.

5. A distribution list

Lists of rows measure well out of the box. reveal="together" keeps them from staggering row-by-row.

vue
<script setup lang="ts">
import { ref } from 'vue';

interface Row {
  label: string;
  value: number;
  share: number;
}

const distribution = ref<Row[] | null>(null);
const SAMPLE: Row[] = [
  { label: 'Chrome', value: 6840, share: 0.55 },
  { label: 'Safari', value: 3920, share: 0.31 },
  { label: 'Edge', value: 980, share: 0.08 },
  { label: 'Firefox', value: 420, share: 0.03 },
  { label: 'Other', value: 310, share: 0.02 },
];
</script>

<template>
  <AGhostSkeleton id="browser-distribution" :data="distribution" :sample="SAMPLE" reveal="together">
    <template #default="{ data }">
      <div class="flex flex-col gap-3">
        <div v-for="row in data" :key="row.label" class="row">
          <span>{{ row.label }}</span>
          <span>{{ row.value.toLocaleString() }}</span>
          <span>{{ (row.share * 100).toFixed(0) }}%</span>
          <div class="bar" :style="{ width: `${row.share * 100}%` }" />
        </div>
      </div>
    </template>
  </AGhostSkeleton>
</template>

6. Four states, one component

The same skeleton resolves loading → error → empty → content. Wire all four slots and the component picks the right one.

vue
<script setup lang="ts">
import { ref } from 'vue';

const report = ref<Report | null>(null);
const fetchError = ref<Error | null>(null);

async function load() {
  report.value = null;
  fetchError.value = null;
  try {
    report.value = await $fetch('/api/report');
  } catch (e) {
    fetchError.value = e as Error;
  }
}
</script>

<template>
  <AGhostSkeleton
    id="four-state-report"
    :data="report"
    :sample="SAMPLE"
    :error="fetchError"
    :empty="report?.rows.length === 0"
  >
    <template #default="{ data }">
      <DataTable :rows="data.rows" />
    </template>

    <template #empty>
      <article class="empty-card">
        <p>No data in this window.</p>
      </article>
    </template>

    <template #error>
      <article class="error-card">
        <p>Couldn't load the report.</p>
        <button @click="load">Retry</button>
      </article>
    </template>
  </AGhostSkeleton>
</template>

Error wins over empty, empty wins over content. A slot you forgot to wire is quietly unrendered.

When the slot can't carry data — compose the fallback

Some pages render a slot from values a composable derives from several sources (a per-filter fetch, an aggregated fetch, an interactive refetch). You can't funnel all of those through the { data } slot argument; make every derivation fall back to the sample while the real payload is still null.

ts
import { computed, ref } from 'vue';
import { SAMPLE } from './sample';

export function useReport() {
  const aggregated = ref<Report | null>(null);
  const perFilter = ref<Row[] | null>(null);

  // Falls back to SAMPLE.rows while both sources are null, so the skeleton
  // measures a full list instead of an empty one.
  const rows = computed(() => perFilter.value ?? aggregated.value?.rows ?? SAMPLE.rows);

  return { aggregated, perFilter, rows };
}

The slot then renders from rows directly and the skeleton captures a real shape even when the outer refs are still loading.

API reference

<AGhostSkeleton> props

PropTypeDefaultDescription
dataT | nullundefinedYour real data. The skeleton is active while this is null / undefined.
sampleTundefinedStand-in used to measure the layout before real data arrives.
loadingbooleanundefinedForce the skeleton on or off, overriding data detection.
idstringundefinedKey for cross-visit shape memory. Highly recommended.
labelstringundefinedScreen-reader text announced in place of the skeleton.
emptybooleanfalseRenders the #empty slot instead of content.
errorunknownundefinedRenders #error="{ error }" slot instead of content.
revealRevealModeprovider / 'smart'Reveal transition. See Reveal transitions.

Slots

SlotReceivesWhen
default{ data }Always, with data = real payload OR the sample during measurement. Destructure and use this binding — see pitfall.
empty—empty is true.
error{ error }error is truthy.
skeleton—Optional hand-authored skeleton — opts out of measurement.

Events

EventPayloadFires when
reveal—Real content takes over from a skeleton, exactly once per cycle.

<AGhostSkeletonProvider> props

PropTypeDescription
themeSkeletonTheme{ light, dark, radius, animation, speed, text }.
mode'light' | 'dark' | 'system'Which theme half to apply.
revealRevealModeDefault reveal for every skeleton inside.
text{ top?, bottom? }Font line each text bar runs from / to.
debugbooleanOutlines every reveal pane and labels its source (sample / data / recall / fallback).
noncestringCSP nonce for the inline boot script and injected stylesheet.

<AGhostSkeletonBox> props

PropTypeDefaultDescription
widthstring100%Any CSS length.
heightstring1emAny CSS length.
radiusstring--ask-radiusAny CSS length.

<AGhostSkeletonSuspense> props

PropTypeDescription
idstringKey for cross-visit shape memory.
labelstringScreen-reader text announced in place of the skeleton.
revealRevealModeOverride for this boundary alone.

Author hints (HTML attributes)

Attributedata-* aliasEffect
ask-blockdata-skeleton-blockDraw as one bone, no recursion.
ask-ignoredata-skeleton-ignoreKeep the layout footprint, emit no bones.
ask-owndata-skeleton-ownSubtree draws its own skeleton.
ask-debugdata-skeleton-debugScope the measurement trace to this subtree.

Thread data through the slot

The sample reaches your child components only through the slot's { data } argument. Ignoring it and reading an outer ref directly means the ref is still null during measurement — children render empty, the ghost captures a half-drawn shape, and the cold-load skeleton is wrong. Warm visits hit the cache (written after real data arrived) and hide the bug.

vue
<!-- ❌ reads the outer ref → rows=[] during measurement -->
<template #default>
  <PagesTable :rows="content?.rows ?? []" />
</template>

<!-- ✅ destructures the slot prop → rows=SAMPLE.rows during measurement -->
<template #default="{ data }">
  <PagesTable :rows="data?.rows ?? []" />
</template>

When the slot pulls from a composable-level computed instead, make sure every derivation falls back to the sample while the real payload is still null:

ts
const daily = computed<IDailyPoint[]>(
  () => kpiSeries.value ?? data.value?.daily ?? SAMPLE_PERFORMANCE.daily
);

Symptom to pattern-match against

A tabbed admin panel rendered its data tables as half-drawn skeletons on cold reload — only the first column had bars, the rest were blank. Warm navigation painted correctly. Cause: <template #default> was ignoring the slot prop and reading the composable's data ref, which was still null during measurement, so the data-table component received rows=[] and memory snapshotted an empty table. Fix was one line per tab — <template #default="{ data }"> — then swap the ref reference for the slot binding.

If you see first-column-only bars on refresh that disappear on warm nav, it's this bug.