@alikhalilll/a-skeleton 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.media/hero.png +0 -0
- package/.media/hero.svg +232 -0
- package/README.md +458 -172
- package/dist/index.cjs +3685 -840
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +527 -40
- package/dist/index.d.ts +527 -40
- package/dist/index.js +3666 -842
- package/dist/index.js.map +1 -1
- package/dist/nuxt/index.cjs +16 -1
- package/dist/nuxt/index.cjs.map +1 -1
- package/dist/nuxt/index.js +16 -1
- package/dist/nuxt/index.js.map +1 -1
- package/dist/resolver/index.cjs +16 -1
- package/dist/resolver/index.cjs.map +1 -1
- package/dist/resolver/index.js +16 -1
- package/dist/resolver/index.js.map +1 -1
- package/dist/styles.css +56 -11
- package/package.json +8 -2
- package/src/components/ASkeleton.vue +212 -113
- package/src/components/ASkeletonClone.vue +106 -0
- package/src/components/ASkeletonLayer.vue +20 -32
- package/src/components/CloneNode.ts +161 -0
- package/src/components/StructuralLayerNode.ts +157 -0
- package/src/components/icons.ts +45 -0
- package/src/components/variants/ASkeletonArticle.vue +33 -0
- package/src/components/variants/ASkeletonAvatar.vue +42 -0
- package/src/components/variants/ASkeletonButton.vue +37 -0
- package/src/components/variants/ASkeletonCard.vue +47 -0
- package/src/components/variants/ASkeletonChart.vue +56 -0
- package/src/components/variants/ASkeletonChip.vue +32 -0
- package/src/components/variants/ASkeletonDivider.vue +26 -0
- package/src/components/variants/ASkeletonForm.vue +32 -0
- package/src/components/variants/ASkeletonHeading.vue +47 -0
- package/src/components/variants/ASkeletonImage.vue +57 -0
- package/src/components/variants/ASkeletonInput.vue +33 -0
- package/src/components/variants/ASkeletonListItem.vue +40 -0
- package/src/components/variants/ASkeletonTable.vue +49 -0
- package/src/components/variants/ASkeletonText.vue +49 -0
- package/src/components/variants/ASkeletonVideo.vue +55 -0
- package/src/composables/useShapeProbe.ts +33 -9
- package/src/composables/useSkeleton.ts +33 -21
- package/src/composables/useSkeletonCache.ts +251 -22
- package/src/index.ts +48 -2
- package/src/nuxt/index.ts +16 -0
- package/src/resolver/index.ts +16 -0
- package/src/types.ts +118 -2
- package/src/utils/buildStructuralSkeleton.ts +400 -103
- package/src/utils/captureStyles.ts +378 -0
- package/src/utils/domRead.ts +143 -0
- package/src/utils/walkDom.ts +261 -16
- package/src/utils/walkStructural.ts +418 -0
- package/web-types.json +9 -3
package/README.md
CHANGED
|
@@ -1,29 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@alikhalilll/a-skeleton`
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> A self-generating skeleton loader for Vue 3 / Nuxt 3+.
|
|
4
|
+
> Three rendering strategies (clone, mirror, structural) · pixel-identical computed-style snapshot ·
|
|
5
|
+
> per-line text geometry via `Range` API · 15 named variants · themeable via CSS variables · SSR-safe mirror mode.
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img
|
|
9
|
+
src="./.media/hero.png"
|
|
10
|
+
alt="ASkeleton — a real profile card on the left, its shimmering skeleton replica on the right, captured and replayed pixel-aligned to the same layout"
|
|
11
|
+
width="820"
|
|
12
|
+
/>
|
|
13
|
+
</p>
|
|
10
14
|
|
|
11
|
-
|
|
15
|
+
<p align="center">
|
|
16
|
+
<sub>One wrapper, three engines — clone (default, pixel-identical) · mirror (SSR-safe) · structural (normal flow).</sub>
|
|
17
|
+
</p>
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
[](https://www.npmjs.com/package/@alikhalilll/a-skeleton)
|
|
20
|
+
[](./LICENSE)
|
|
21
|
+
[](https://www.npmjs.com/package/@alikhalilll/a-skeleton)
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
14
24
|
|
|
15
|
-
|
|
25
|
+
### Nuxt 3 / 4
|
|
16
26
|
|
|
17
27
|
```bash
|
|
18
28
|
pnpm add @alikhalilll/a-skeleton
|
|
29
|
+
# npm install @alikhalilll/a-skeleton
|
|
30
|
+
# yarn add @alikhalilll/a-skeleton
|
|
31
|
+
# bun add @alikhalilll/a-skeleton
|
|
19
32
|
```
|
|
20
33
|
|
|
21
|
-
```ts
|
|
22
|
-
import '@alikhalilll/a-skeleton/styles.css';
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
### Nuxt 3 / 4
|
|
26
|
-
|
|
27
34
|
```ts
|
|
28
35
|
// nuxt.config.ts
|
|
29
36
|
export default defineNuxtConfig({
|
|
@@ -32,9 +39,20 @@ export default defineNuxtConfig({
|
|
|
32
39
|
});
|
|
33
40
|
```
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
`<ASkeleton>`, `<ASkeletonLayer>`, `<ASkeletonBlock>`, and all variant primitives (`<ASkeletonCard>`, `<ASkeletonText>`, …) are auto-imported — no `import` statement needed in your `.vue` files.
|
|
43
|
+
|
|
44
|
+
### Vue + Vite
|
|
36
45
|
|
|
37
|
-
|
|
46
|
+
```bash
|
|
47
|
+
pnpm add @alikhalilll/a-skeleton
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// main.ts
|
|
52
|
+
import '@alikhalilll/a-skeleton/styles.css';
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Optional auto-resolve via `unplugin-vue-components`:
|
|
38
56
|
|
|
39
57
|
```ts
|
|
40
58
|
// vite.config.ts
|
|
@@ -46,30 +64,56 @@ export default { plugins: [Components({ resolvers: [ASkeletonResolver()] })] };
|
|
|
46
64
|
|
|
47
65
|
---
|
|
48
66
|
|
|
49
|
-
##
|
|
50
|
-
|
|
51
|
-
| Export | Kind | When to reach for it |
|
|
52
|
-
| ----------------------------------------- | ---------- | -------------------------------------------------------------------------------- |
|
|
53
|
-
| `<ASkeleton>` | Component | The default wrapper. Two-layer flow (structural pass → measured cache). |
|
|
54
|
-
| `<ASkeletonLayer>` | Component | Render a `CachedShape` directly. Use when you don't want the wrapper. |
|
|
55
|
-
| `<ASkeletonBlock>` | Component | Hand-crafted skeleton from primitive blocks. Flow-layout friendly. |
|
|
56
|
-
| `<StructuralSkeleton>` | Component | Render a structural skeleton from any vnode tree (the cache-miss render path). |
|
|
57
|
-
| `useSkeleton()` | Composable | Wire probe + cache + reactivity around your own DOM. Returns a reactive `shape`. |
|
|
58
|
-
| `useShapeProbe()` | Composable | Lower-level. `ResizeObserver` + debounced capture; you manage the cache. |
|
|
59
|
-
| `getCached` / `setCached` / `clearCached` | Function | Imperative cache primitives. Read / write / wipe entries by key. |
|
|
60
|
-
| `walkDom()` | Function | Synchronous one-shot measurement. Returns a `CachedShape`. |
|
|
61
|
-
| `buildStructuralSkeleton()` | Function | Pure: walk a vnode tree, return skeleton vnodes mirroring its layout. |
|
|
62
|
-
| `fingerprintSlot()` | Function | Default `cacheKey` derivation — first non-comment vnode's component name. |
|
|
67
|
+
## Why this component
|
|
63
68
|
|
|
64
|
-
|
|
69
|
+
- **Self-generating** — wrap your real component and the engine derives the placeholder from the slot you're already rendering. No hand-drawn shimmer markup, no parallel "loading state" templates to maintain.
|
|
70
|
+
- **Three rendering strategies, one wrapper** — `mode="clone"` (default) snapshots `getComputedStyle()` for pixel-identical replay regardless of styling pipeline; `mode="mirror"` walks the vnode tree for SSR-safe placeholders; `useSkeleton()` + `<ASkeletonLayer>` for structural / normal-flow skeletons that reflow with their parent.
|
|
71
|
+
- **Comprehensive surface capture** — per-edge borders, per-corner radii, background colour + image, box-shadow, opacity, filter, transform, mix-blend-mode, typography — every visible CSS property carries through so the skeleton reads as the real element, not as a generic shimmer.
|
|
72
|
+
- **Per-line text geometry** — `Range.getClientRects()` captures the exact rendered text rect of every line. Wrapped paragraphs, centred multi-line headings, and RTL last-line positions replay 1:1 — no heuristics.
|
|
73
|
+
- **15 named variants** — `<ASkeletonCard>`, `<ASkeletonText>`, `<ASkeletonHeading>`, `<ASkeletonAvatar>`, `<ASkeletonImage>`, `<ASkeletonVideo>`, `<ASkeletonButton>`, `<ASkeletonInput>`, `<ASkeletonChip>`, `<ASkeletonListItem>`, `<ASkeletonTable>`, `<ASkeletonChart>`, `<ASkeletonForm>`, `<ASkeletonArticle>`, `<ASkeletonDivider>`. Each accepts `class` / inline `style` and shares the same animation + theming pipeline.
|
|
74
|
+
- **Bounded cost** — every walker enforces `maxDepth` / `maxNodes` / `minSize`. A 5 000-row table will not lock up the main thread. Captured nodes carry frozen pre-computed styles so the render loop is allocation-free.
|
|
75
|
+
- **Themeable via CSS variables** — `--ak-skel-base`, `--ak-skel-highlight`, `--ak-skel-radius`, `--ak-skel-duration`, `--ak-skel-pulse-min`, `--ak-skel-ring`, `--ak-skel-icon`. Override on `:root`, a wrapper class (multi-tenant), or inline.
|
|
76
|
+
- **Empty-interpolation aware** — `<h3>{{ data?.name }}</h3>` with null data still shimmers at the heading's natural rendered height. Both `mode="clone"` and `mode="mirror"` classify empty text-owner tags (`<h1>`-`<h6>`, `<p>`, `<span>`, …) as text bars rather than generic blocks.
|
|
77
|
+
- **`prefers-reduced-motion`** disables animation automatically.
|
|
78
|
+
- **SSR-safe (mirror)** — no `window` access during the structural pass; hydration is clean.
|
|
79
|
+
- **TypeScript-first** — every prop, slot, type, and composable fully typed; web-types ship for JetBrains IDEs.
|
|
65
80
|
|
|
66
81
|
---
|
|
67
82
|
|
|
68
|
-
##
|
|
83
|
+
## Table of contents
|
|
84
|
+
|
|
85
|
+
- [Setup](#setup)
|
|
86
|
+
- [Quick start](#quick-start)
|
|
87
|
+
- [Three rendering strategies](#three-rendering-strategies)
|
|
88
|
+
- [Clone (default)](#clone-default)
|
|
89
|
+
- [Mirror](#mirror)
|
|
90
|
+
- [Structural / normal-flow](#structural--normal-flow)
|
|
91
|
+
- [Authoring rule](#authoring-rule)
|
|
92
|
+
- [API reference](#api-reference)
|
|
93
|
+
- [`<ASkeleton>` props](#askeleton-props)
|
|
94
|
+
- [Slots](#slots)
|
|
95
|
+
- [DOM escape hatches](#dom-escape-hatches)
|
|
96
|
+
- [Variant primitives](#variant-primitives)
|
|
97
|
+
- [`<ASkeletonBlock>`](#askeletonblock)
|
|
98
|
+
- [`<ASkeletonLayer>`](#askeletonlayer)
|
|
99
|
+
- [Composables](#composables)
|
|
100
|
+
- [Pure utilities](#pure-utilities)
|
|
101
|
+
- [Cache primitives](#cache-primitives)
|
|
102
|
+
- [Theming](#theming)
|
|
103
|
+
- [CSS custom properties](#css-custom-properties)
|
|
104
|
+
- [Multi-tenant + dark mode](#multi-tenant--dark-mode)
|
|
105
|
+
- [Animations](#animations)
|
|
106
|
+
- [Accessibility](#accessibility)
|
|
107
|
+
- [Performance](#performance)
|
|
108
|
+
- [SSR](#ssr)
|
|
109
|
+
- [TypeScript](#typescript)
|
|
110
|
+
- [Browser support](#browser-support)
|
|
111
|
+
- [Troubleshooting](#troubleshooting)
|
|
112
|
+
- [License](#license)
|
|
69
113
|
|
|
70
|
-
|
|
114
|
+
---
|
|
71
115
|
|
|
72
|
-
|
|
116
|
+
## Quick start
|
|
73
117
|
|
|
74
118
|
```vue
|
|
75
119
|
<script setup lang="ts">
|
|
@@ -85,55 +129,79 @@ load();
|
|
|
85
129
|
</script>
|
|
86
130
|
|
|
87
131
|
<template>
|
|
88
|
-
<ASkeleton :loading="loading"
|
|
89
|
-
<!-- Keep
|
|
90
|
-
|
|
132
|
+
<ASkeleton :loading="loading">
|
|
133
|
+
<!-- Keep TAGS unconditional (the walker sees the same shape in both
|
|
134
|
+
states); gate per-leaf CONTENT via interpolation. -->
|
|
91
135
|
<div class="flex items-start gap-4 p-4">
|
|
92
|
-
<img
|
|
93
|
-
|
|
94
|
-
|
|
136
|
+
<img
|
|
137
|
+
:src="user?.avatar"
|
|
138
|
+
:alt="user?.name ?? ''"
|
|
139
|
+
class="size-16 shrink-0 rounded-full object-cover"
|
|
140
|
+
/>
|
|
95
141
|
<div class="flex-1">
|
|
96
|
-
<h3>{{ user?.name }}</h3>
|
|
97
|
-
<p>{{ user?.bio }}</p>
|
|
142
|
+
<h3 class="text-base font-semibold">{{ user?.name }}</h3>
|
|
143
|
+
<p class="text-sm leading-relaxed">{{ user?.bio }}</p>
|
|
98
144
|
</div>
|
|
99
145
|
</div>
|
|
100
146
|
</ASkeleton>
|
|
101
147
|
</template>
|
|
102
148
|
```
|
|
103
149
|
|
|
104
|
-
|
|
150
|
+
That's the whole API for most cases. Every layer underneath is also a public export — see the recipes below.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Three rendering strategies
|
|
155
|
+
|
|
156
|
+
Pick the one that matches how your styles get to the DOM:
|
|
105
157
|
|
|
106
|
-
|
|
158
|
+
| Strategy | Surface | How geometry is derived | Best when |
|
|
159
|
+
| --------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
160
|
+
| **Clone** _(default)_ | `<ASkeleton :loading>` | Mounts the slot off-screen, snapshots every leaf's **computed style** via `getComputedStyle()`, replays as positioned divs each carrying its captured inline CSS. | Styles come from any pipeline — class names, inline `style`, CSS-in-JS, scoped styles. Pixel-identical. Client-side only. |
|
|
161
|
+
| **Mirror** | `<ASkeleton mode="mirror" :loading>` | Walks the slot's **vnode** tree at render time. Preserves every tag and `class`; leaves become shimmer bars. | SSR-safe placeholders (no DOM read needed) or when the static class is enough to drive the surface. |
|
|
162
|
+
| **Structural** | `useSkeleton()` + `<ASkeletonLayer>` | Walks the real **DOM** tree, preserves container tags + classes + layout CSS (`display`, `flex-*`, `grid-*`, `gap`, `padding`), replaces leaves with `<div class="a-skel">` in **normal flow**. | You want the skeleton to reflow with its parent, or you're orchestrating cache + capture outside a single wrapper instance. |
|
|
107
163
|
|
|
108
|
-
|
|
164
|
+
All three share one cache module, one theming surface, and the same a11y baseline.
|
|
165
|
+
|
|
166
|
+
### Clone (default)
|
|
167
|
+
|
|
168
|
+
The slot mounts off-screen inside a `visibility: hidden` capture host. After mount, `captureSnapshot()` reads `getComputedStyle()` for every element under the host — capturing the **final** background, per-edge border, per-corner radius, box-shadow, padding, opacity, filter, transform, typography — plus per-line text rects via `Range.getClientRects()`. `<ASkeletonClone>` then replays the snapshot as a tree of positioned divs each carrying its captured inline style.
|
|
109
169
|
|
|
110
170
|
```vue
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
|
|
114
|
-
<div class="flex-1 space-y-2">
|
|
115
|
-
<ASkeletonBlock type="text" :w="160" :h="18" />
|
|
116
|
-
<ASkeletonBlock type="text" :w="100" :h="12" />
|
|
117
|
-
<ASkeletonBlock type="text" :lines="3" :h="14" class="!mt-3" />
|
|
118
|
-
</div>
|
|
119
|
-
</div>
|
|
120
|
-
<UserCard v-else :data="user" />
|
|
121
|
-
</template>
|
|
171
|
+
<ASkeleton :loading="loading">
|
|
172
|
+
<SomeRichComponent />
|
|
173
|
+
</ASkeleton>
|
|
122
174
|
```
|
|
123
175
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
176
|
+
The capture API is a public export — power-users can roll their own capture / replay flow:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { captureSnapshot, type CaptureSnapshot } from '@alikhalilll/a-skeleton';
|
|
180
|
+
|
|
181
|
+
const snap: CaptureSnapshot = captureSnapshot(myRootEl, {
|
|
182
|
+
maxDepth: 12,
|
|
183
|
+
maxNodes: 800,
|
|
184
|
+
minSize: 4,
|
|
185
|
+
});
|
|
186
|
+
```
|
|
133
187
|
|
|
134
|
-
###
|
|
188
|
+
### Mirror
|
|
135
189
|
|
|
136
|
-
|
|
190
|
+
Switch to `mode="mirror"` when you need the placeholder on the server, or when your styles already drive the surface correctly from class names alone:
|
|
191
|
+
|
|
192
|
+
```vue
|
|
193
|
+
<ASkeleton mode="mirror" :loading="loading">
|
|
194
|
+
<SomeRichComponent />
|
|
195
|
+
</ASkeleton>
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`buildStructuralSkeleton()` walks the slot's vnode tree at render time, preserves every element with its real `class` / inline `style`, and replaces text-bearing leaves with `<span class="a-skel-text-content">` (transparent text + skeleton background, exact rendered width via `box-decoration-break: clone`). Atomic / interactive tags (`<img>`, `<button>`, `<svg>`) become `<div class="a-skel-block">` sized from their original class.
|
|
199
|
+
|
|
200
|
+
Text-owner tags with empty content (`<h3>{{ data?.name }}</h3>` during loading) auto-shimmer at the heading's natural rendered height — the walker injects a placeholder text-content span so the bar's height tracks the tag's font-size / line-height.
|
|
201
|
+
|
|
202
|
+
### Structural / normal-flow
|
|
203
|
+
|
|
204
|
+
For cases where the wrapper isn't your unit of orchestration — caching shapes across instances, persisting between sessions, or wanting the skeleton to reflow with its parent — reach for `useSkeleton()` + `<ASkeletonLayer>`:
|
|
137
205
|
|
|
138
206
|
```vue
|
|
139
207
|
<script setup lang="ts">
|
|
@@ -145,7 +213,7 @@ const user = ref(null);
|
|
|
145
213
|
const loading = computed(() => user.value === null);
|
|
146
214
|
const containerRef = ref<HTMLElement | null>(null);
|
|
147
215
|
|
|
148
|
-
const { shape,
|
|
216
|
+
const { shape, clear } = useSkeleton({
|
|
149
217
|
cacheKey: `user-card:${props.userId}`,
|
|
150
218
|
// While loading, target is null → no capture. When real content mounts,
|
|
151
219
|
// target returns the wrapper → ResizeObserver + capture.
|
|
@@ -158,170 +226,388 @@ fetchUser(props.userId).then((u) => (user.value = u));
|
|
|
158
226
|
|
|
159
227
|
<template>
|
|
160
228
|
<div ref="containerRef">
|
|
161
|
-
<ASkeletonLayer v-if="loading" :shape="shape" />
|
|
229
|
+
<ASkeletonLayer v-if="loading && shape" :shape="shape" />
|
|
230
|
+
<ColdStartFallback v-else-if="loading" />
|
|
162
231
|
<UserCard v-else :data="user" />
|
|
163
232
|
</div>
|
|
164
233
|
</template>
|
|
165
234
|
```
|
|
166
235
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
**`useSkeleton` returns** — `{ shape: Readonly<Ref<CachedShape | undefined>>, captureNow: () => CachedShape | undefined, clear: () => void }`.
|
|
236
|
+
`walkStructural()` produces a frozen tree where containers preserve their tag, original `class`, and captured layout CSS (`display`, `flex-*`, `gap`, `padding`, `grid-*`, `box-sizing`), and leaves carry inline `width` / `height` + visual signals. `<ASkeletonLayer>` replays the tree in **normal flow**, so the skeleton lives inside its parent's layout instead of overlaying it with absolute coordinates — it reflows on viewport resize and never collapses to a flat positioned grid.
|
|
170
237
|
|
|
171
|
-
|
|
238
|
+
Given real markup like:
|
|
172
239
|
|
|
173
|
-
|
|
240
|
+
```html
|
|
241
|
+
<div class="flex flex-col gap-4 p-4">
|
|
242
|
+
<h3>…</h3>
|
|
243
|
+
<p>…</p>
|
|
244
|
+
<button>…</button>
|
|
245
|
+
</div>
|
|
246
|
+
```
|
|
174
247
|
|
|
175
|
-
|
|
248
|
+
…the layer replays as:
|
|
249
|
+
|
|
250
|
+
```html
|
|
251
|
+
<div
|
|
252
|
+
class="flex flex-col gap-4 p-4"
|
|
253
|
+
style="display: flex; flex-direction: column; gap: 16px; padding: 16px; box-sizing: border-box"
|
|
254
|
+
>
|
|
255
|
+
<div class="a-skel" style="width: 200px; height: 24px; …" />
|
|
256
|
+
<div class="a-skel" style="width: 280px; height: 16px; …" />
|
|
257
|
+
<div class="a-skel" style="width: 120px; height: 36px; …" />
|
|
258
|
+
</div>
|
|
259
|
+
```
|
|
176
260
|
|
|
177
|
-
|
|
178
|
-
import {
|
|
179
|
-
walkDom,
|
|
180
|
-
buildStructuralSkeleton,
|
|
181
|
-
fingerprintSlot,
|
|
182
|
-
setCached,
|
|
183
|
-
getCached,
|
|
184
|
-
} from '@alikhalilll/a-skeleton';
|
|
261
|
+
The original class is preserved so utility-first CSS still applies; the resolved layout CSS is inlined as a fallback so the skeleton looks correct even when the stylesheet isn't at the mount point.
|
|
185
262
|
|
|
186
|
-
|
|
187
|
-
const shape = walkDom(containerEl, { maxDepth: 6, maxNodes: 500, minSize: 4 });
|
|
188
|
-
setCached('user-card', shape, /* persist */ true);
|
|
263
|
+
The structural cache lives in its own `localStorage` namespace (`a-skeleton:s:` prefix, schema `v: 3`), so it can never collide on the same `cacheKey` with the flat `CachedShape` cache. Mismatched schema versions auto-purge on read.
|
|
189
264
|
|
|
190
|
-
|
|
191
|
-
const key = fingerprintSlot(slots.default?.()); // 'UserCard' or 'div' or 'anonymous'
|
|
265
|
+
---
|
|
192
266
|
|
|
193
|
-
|
|
194
|
-
const skeletonVNodes = buildStructuralSkeleton(slots.default?.() ?? [], {
|
|
195
|
-
animationClass: 'a-skel-block--anim-shimmer',
|
|
196
|
-
maxNodes: 300,
|
|
197
|
-
});
|
|
198
|
-
```
|
|
267
|
+
## Authoring rule
|
|
199
268
|
|
|
200
|
-
|
|
269
|
+
Keep **tags** unconditional; gate per-leaf **content** via interpolation. Two safe patterns:
|
|
201
270
|
|
|
202
|
-
|
|
271
|
+
1. **Always render the same tag**, gate its content.
|
|
272
|
+
- `<img :src="user?.avatar">` renders an `<img>` in both states (walker treats it as atomic → sized shimmer block).
|
|
273
|
+
- `<h3>{{ user?.name }}</h3>` renders an empty `<h3>` during loading and the walker auto-injects a placeholder shimmer bar at the heading's natural width.
|
|
274
|
+
2. **Use explicit primitives** with `v-if` / `v-else` (see [`<ASkeletonBlock>`](#askeletonblock) and the [Variant primitives](#variant-primitives)) when the loading and loaded states genuinely have different markup.
|
|
203
275
|
|
|
204
|
-
|
|
205
|
-
import { useShapeProbe } from '@alikhalilll/a-skeleton';
|
|
206
|
-
|
|
207
|
-
useShapeProbe(() => containerRef.value, {
|
|
208
|
-
maxDepth: 6,
|
|
209
|
-
maxNodes: 500,
|
|
210
|
-
resizeDebounceMs: 200,
|
|
211
|
-
onCapture: (shape) => myStore.saveShape('user-card', shape),
|
|
212
|
-
});
|
|
213
|
-
```
|
|
276
|
+
The pattern to **avoid** is swapping whole branches (`<img v-if><div v-else>`). The walker sees one shape now and a different shape later — the skeleton can't predict the placeholder geometry.
|
|
214
277
|
|
|
215
278
|
---
|
|
216
279
|
|
|
217
|
-
##
|
|
280
|
+
## API reference
|
|
218
281
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
|
222
|
-
|
|
|
223
|
-
| `
|
|
224
|
-
| `
|
|
225
|
-
| `
|
|
226
|
-
| `
|
|
227
|
-
| `
|
|
228
|
-
| `
|
|
229
|
-
| `
|
|
282
|
+
### `<ASkeleton>` props
|
|
283
|
+
|
|
284
|
+
| Prop | Type | Default | Description |
|
|
285
|
+
| ------------- | -------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
286
|
+
| `loading` | `boolean` | — | When `true`, show the skeleton. |
|
|
287
|
+
| `mode` | `'clone' \| 'mirror'` | `'clone'` | Rendering strategy. `clone` snapshots `getComputedStyle()`; `mirror` walks the vnode tree (SSR-safe). |
|
|
288
|
+
| `cacheKey` | `string` | auto, per-instance | Auto-generated as `<slot-fingerprint>:<useId()>` so each instance has its own slot. Pass explicitly to share a captured shape across instances (e.g. a list of identical cards) or to differentiate prop-variant shapes from the same component. |
|
|
289
|
+
| `maxDepth` | `number` | `16` | Max recursion depth when capturing. |
|
|
290
|
+
| `maxNodes` | `number` | `600` | Hard cap on captured / structural nodes. Walks bail beyond this with `truncated: true` and a one-time `console.warn` per `cacheKey`. |
|
|
291
|
+
| `minNodeSize` | `number` | `4` | Skip elements smaller than this many CSS pixels (either axis). Drops hairlines / spacer dots. |
|
|
292
|
+
| `persist` | `boolean` | `false` | Mirror captured shape to `localStorage`. Schema-versioned; entries from older releases auto-purge on read. |
|
|
293
|
+
| `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. `prefers-reduced-motion` disables animation automatically. |
|
|
294
|
+
| `fallback` | `'shimmer' \| 'block'` | `'shimmer'` | Default cache-miss UI when no `#fallback` slot is provided (mirror mode only). |
|
|
295
|
+
| `class` | `HTMLAttributes['class']` | — | Class on the outer wrapper. |
|
|
230
296
|
|
|
231
297
|
### Slots
|
|
232
298
|
|
|
233
|
-
| Slot | Description
|
|
234
|
-
| ---------- |
|
|
235
|
-
| `default` | The real content. Rendered when `loading` is false; measured to build the skeleton. |
|
|
236
|
-
| `fallback` | Custom UI for cache misses. Defaults to a single full-width shimmer block.
|
|
299
|
+
| Slot | Description |
|
|
300
|
+
| ---------- | ---------------------------------------------------------------------------------------------- |
|
|
301
|
+
| `default` | The real content. Rendered when `loading` is false; measured / mirrored to build the skeleton. |
|
|
302
|
+
| `fallback` | Custom UI for cache misses (mirror mode). Defaults to a single full-width shimmer block. |
|
|
237
303
|
|
|
238
304
|
### DOM escape hatches
|
|
239
305
|
|
|
240
|
-
Mark elements during the walk:
|
|
306
|
+
Mark elements during the walk via data attributes — applies to all three strategies:
|
|
307
|
+
|
|
308
|
+
| Attribute | Effect |
|
|
309
|
+
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
310
|
+
| `data-skeleton-stop` | Stop recursing into this element — render as a single block carrying its outer `class` / `style`. |
|
|
311
|
+
| `data-skeleton-ignore` | Skip the element entirely (no block emitted). Use for decorative chrome (background SVGs, dividers, persistent badges) that should always render verbatim. |
|
|
312
|
+
|
|
313
|
+
Authoring rule for branded chrome — skeleton-ise **content**, not **chrome**. If a component's identity is its gradient background / decorative SVGs, wrap only the inner content with `<ASkeleton>` and let the container always render. Mark decorations with `data-skeleton-ignore` so the walker treats them as invisible.
|
|
314
|
+
|
|
315
|
+
### Variant primitives
|
|
316
|
+
|
|
317
|
+
When auto-capture isn't the right fit (loading states without real content to wrap, very dense layouts, screens you'd rather author once), drop in a named variant. Each accepts `animation="pulse | shimmer | wave | none"`, `class`, and inline `style`. Every variant ships `role="status"`, `aria-busy="true"`, and a visually-hidden `<span class="a-skel-sr-only">Loading…</span>`.
|
|
318
|
+
|
|
319
|
+
| Component | Maps to | Key props |
|
|
320
|
+
| --------------------- | ------------------------------------------ | ------------------------------------------------------ |
|
|
321
|
+
| `<ASkeletonText>` | n stacked bars, last line shorter | `lines`, `width` |
|
|
322
|
+
| `<ASkeletonHeading>` | one bar sized to heading level | `level` (1–6), `width` |
|
|
323
|
+
| `<ASkeletonAvatar>` | circle / square / rounded | `size`, `shape` |
|
|
324
|
+
| `<ASkeletonImage>` | aspect-ratio rect + image-icon placeholder | `ratio`, `width`, `height`, `showIcon` |
|
|
325
|
+
| `<ASkeletonVideo>` | rect + play-icon placeholder | `ratio`, `width`, `height`, `showIcon` |
|
|
326
|
+
| `<ASkeletonButton>` | rounded rect, filled or outlined | `width`, `height`, `outlined` |
|
|
327
|
+
| `<ASkeletonInput>` | bordered rect with caret bar inside | `width`, `height` |
|
|
328
|
+
| `<ASkeletonChip>` | small pill | `width`, `height` |
|
|
329
|
+
| `<ASkeletonListItem>` | avatar + n text lines + trailing slot | `avatar`, `lines`, `trailing` |
|
|
330
|
+
| `<ASkeletonCard>` | media + heading + paragraph + actions | `media`, `heading`, `lines`, `actions`, `footerAvatar` |
|
|
331
|
+
| `<ASkeletonTable>` | header row + n × m body cells | `rows`, `columns`, `showHeader` |
|
|
332
|
+
| `<ASkeletonChart>` | n vertical bars of varying heights | `bars`, `height`, `showHeader` |
|
|
333
|
+
| `<ASkeletonForm>` | label + input pairs + submit | `fields`, `showSubmit` |
|
|
334
|
+
| `<ASkeletonArticle>` | heading + media + n paragraphs | `media`, `paragraphs`, `linesPerParagraph` |
|
|
335
|
+
| `<ASkeletonDivider>` | thin shimmer rule | `thickness` |
|
|
336
|
+
|
|
337
|
+
### `<ASkeletonBlock>`
|
|
241
338
|
|
|
242
|
-
|
|
243
|
-
| ---------------------- | --------------------------------------------------------------- |
|
|
244
|
-
| `data-skeleton-stop` | Stop recursing into this element — render it as a single block. |
|
|
245
|
-
| `data-skeleton-ignore` | Skip this element entirely (no block emitted). |
|
|
339
|
+
Single-block primitive for hand-crafted skeletons. Flow-layout friendly — composes with flex, grid, stacks.
|
|
246
340
|
|
|
247
|
-
|
|
341
|
+
```vue
|
|
342
|
+
<template>
|
|
343
|
+
<div v-if="loading" class="flex items-start gap-4 p-4">
|
|
344
|
+
<ASkeletonBlock type="circle" :w="64" :h="64" />
|
|
345
|
+
<div class="flex-1 space-y-2">
|
|
346
|
+
<ASkeletonBlock type="text" :w="160" :h="18" />
|
|
347
|
+
<ASkeletonBlock type="text" :w="100" :h="12" />
|
|
348
|
+
<ASkeletonBlock type="text" :lines="3" :h="14" class="!mt-3" />
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
<UserCard v-else :data="user" />
|
|
352
|
+
</template>
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
| Prop | Type | Default | Notes |
|
|
356
|
+
| ----------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------- |
|
|
357
|
+
| `type` | `'block' \| 'text' \| 'image' \| 'circle'` | `'block'` | `circle` defaults `border-radius: 50%`. |
|
|
358
|
+
| `w` | `number \| string` | — | Width (number = px). |
|
|
359
|
+
| `h` | `number \| string` | — | Height (number = px). |
|
|
360
|
+
| `radius` | `number \| string` | — | Border radius (number = px). |
|
|
361
|
+
| `lines` | `number` | `1` | For `type='text'`, render N stacked bars; last is 70% width. |
|
|
362
|
+
| `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. |
|
|
363
|
+
| `class` | `HTMLAttributes['class']` | — | Class on the root (single block, or the stack for multi-line text). |
|
|
364
|
+
|
|
365
|
+
### `<ASkeletonLayer>`
|
|
366
|
+
|
|
367
|
+
Renders a `StructuralShape` (from `useSkeleton()` or `walkStructural()`) in normal flow. The layer is a transparent shell — captured containers carry the layout.
|
|
368
|
+
|
|
369
|
+
| Prop | Type | Default | Description |
|
|
370
|
+
| ----------- | -------------------------------- | ----------- | --------------------------------- |
|
|
371
|
+
| `shape` | `StructuralShape \| undefined` | — | Renders nothing when `undefined`. |
|
|
372
|
+
| `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. |
|
|
373
|
+
| `class` | `HTMLAttributes['class']` | — | Class on the layer wrapper. |
|
|
374
|
+
|
|
375
|
+
### Composables
|
|
376
|
+
|
|
377
|
+
#### `useSkeleton(options) → { shape, captureNow, clear }`
|
|
378
|
+
|
|
379
|
+
Wires the DOM probe + structural cache + reactivity around a target element. The reactive `shape` feeds `<ASkeletonLayer>`.
|
|
380
|
+
|
|
381
|
+
| Option | Type | Default | Description |
|
|
382
|
+
| ------------------ | --------------------------- | ------- | -------------------------------------------------------------------- |
|
|
383
|
+
| `cacheKey` | `string` | — | Required. Identifier for the shape cache. |
|
|
384
|
+
| `target` | `() => HTMLElement \| null` | — | Getter for the element to measure. Return `null` to disable capture. |
|
|
385
|
+
| `persist` | `boolean` | `false` | Mirror captured shape to `localStorage`. |
|
|
386
|
+
| `maxDepth` | `number` | `12` | Forwarded to `walkStructural`. |
|
|
387
|
+
| `maxNodes` | `number` | `500` | Forwarded to `walkStructural`. |
|
|
388
|
+
| `minSize` | `number` | `4` | Forwarded to `walkStructural`. |
|
|
389
|
+
| `resizeDebounceMs` | `number` | `150` | `ResizeObserver` re-capture debounce. |
|
|
390
|
+
|
|
391
|
+
Returns `{ shape: Readonly<Ref<StructuralShape | undefined>>, captureNow: () => StructuralShape | undefined, clear: () => void }`.
|
|
392
|
+
|
|
393
|
+
#### `useShapeProbe(getTarget, options)`
|
|
394
|
+
|
|
395
|
+
Lower-level — `ResizeObserver` + debounced capture without the cache. You manage persistence (Pinia, API, server-rendered geometry). The `capture` option lets you swap in any strategy:
|
|
248
396
|
|
|
249
397
|
```ts
|
|
250
|
-
import {
|
|
398
|
+
import { useShapeProbe, walkStructural } from '@alikhalilll/a-skeleton';
|
|
251
399
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
400
|
+
useShapeProbe(() => containerRef.value, {
|
|
401
|
+
maxDepth: 12,
|
|
402
|
+
resizeDebounceMs: 200,
|
|
403
|
+
capture: walkStructural, // default is walkDom (flat); pass walkStructural for the tree shape.
|
|
404
|
+
onCapture: (shape) => myStore.saveShape('user-card', shape),
|
|
405
|
+
});
|
|
256
406
|
```
|
|
257
407
|
|
|
258
|
-
|
|
408
|
+
### Pure utilities
|
|
409
|
+
|
|
410
|
+
When none of the components fit, drop down to the pure functions:
|
|
411
|
+
|
|
412
|
+
| Symbol | Returns | Purpose |
|
|
413
|
+
| ------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------- |
|
|
414
|
+
| `walkDom(el, options)` | `CachedShape` (flat) | One-shot synchronous capture — positioned-block model, root-relative absolute coords. |
|
|
415
|
+
| `walkStructural(el, options)` | `StructuralShape` (tree) | One-shot synchronous capture — preserves container layout, normal-flow replay. |
|
|
416
|
+
| `captureSnapshot(el, options)` | `CaptureSnapshot` (tree) | Comprehensive computed-style snapshot used by clone mode. |
|
|
417
|
+
| `buildStructuralSkeleton(vnodes, options)` | `VNode[]` | Mirror-mode renderer for any vnode tree (e.g. inside a render-function component). |
|
|
418
|
+
| `fingerprintSlot(vnodes)` | `string` | Slot-name fragment of the auto `cacheKey` (`'UserCard'`, `'div'`, or `'anonymous'`). |
|
|
419
|
+
|
|
420
|
+
### Cache primitives
|
|
421
|
+
|
|
422
|
+
| Symbol | Namespace | Purpose |
|
|
423
|
+
| ------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------ |
|
|
424
|
+
| `getCached(key, persist)` | flat (`a-skeleton:` prefix, `v: 2`) | Lookup for the legacy flat-shape cache (mirror cache-replay path). |
|
|
425
|
+
| `setCached(key, value, persist)` | flat | Store a flat shape. |
|
|
426
|
+
| `clearCached(key?)` | flat **and** structural | Wipes both namespaces. Pass a `key` to drop one entry from each. |
|
|
427
|
+
| `getCachedStructural(key, persist)` | structural (`a-skeleton:s:` prefix, `v: 3`) | Lookup for the tree-shape cache (Recipe 3). |
|
|
428
|
+
| `setCachedStructural(key, value, persist)` | structural | Store a tree shape. |
|
|
429
|
+
| `clearCachedStructural(key?)` | structural only | Wipes only the structural namespace. |
|
|
430
|
+
|
|
431
|
+
Persisted entries carry a schema version. Mismatched versions auto-purge on read — upgrades can't replay wrong geometry from a previous version's cache.
|
|
259
432
|
|
|
260
433
|
---
|
|
261
434
|
|
|
262
435
|
## Theming
|
|
263
436
|
|
|
264
|
-
|
|
437
|
+
### CSS custom properties
|
|
438
|
+
|
|
439
|
+
Set these on `:root` (or any ancestor) to retint every primitive:
|
|
440
|
+
|
|
441
|
+
| Token | Used for |
|
|
442
|
+
| --------------------- | ------------------------------------------------------------- |
|
|
443
|
+
| `--ak-skel-base` | Block fill (also used by text bars and variant primitives). |
|
|
444
|
+
| `--ak-skel-base-soft` | Secondary endpoint for variants that use a vertical gradient. |
|
|
445
|
+
| `--ak-skel-highlight` | Shimmer / wave sweep colour. |
|
|
446
|
+
| `--ak-skel-radius` | Default block border radius. |
|
|
447
|
+
| `--ak-skel-radius-sm` | Tighter radius for text bars, chips. |
|
|
448
|
+
| `--ak-skel-radius-lg` | Wider radius for cards, images. |
|
|
449
|
+
| `--ak-skel-duration` | Animation cycle length. |
|
|
450
|
+
| `--ak-skel-pulse-min` | Opacity at the trough of the pulse cycle. |
|
|
451
|
+
| `--ak-skel-ring` | Subtle 1-px inset ring colour. |
|
|
452
|
+
| `--ak-skel-icon` | Placeholder icon colour (image / video variants). |
|
|
453
|
+
|
|
454
|
+
Backward-compat aliases for v1 token names (`--ak-skeleton-block`, `--ak-skeleton-shimmer`, `--ak-skeleton-radius`, `--ak-skeleton-duration`, `--ak-skeleton-pulse-opacity`, `--ak-skeleton-ring`) are kept — existing consumer overrides continue to work.
|
|
265
455
|
|
|
266
456
|
```css
|
|
267
457
|
/* Per-tenant override — applies to anything inside .tenant-acme */
|
|
268
458
|
.tenant-acme {
|
|
269
|
-
--ak-
|
|
270
|
-
--ak-
|
|
271
|
-
--ak-
|
|
272
|
-
--ak-
|
|
459
|
+
--ak-skel-base: hsl(220 30% 18%);
|
|
460
|
+
--ak-skel-highlight: hsl(220 60% 60% / 0.35);
|
|
461
|
+
--ak-skel-radius: 0.5rem;
|
|
462
|
+
--ak-skel-duration: 2s;
|
|
273
463
|
}
|
|
274
464
|
```
|
|
275
465
|
|
|
276
466
|
```vue
|
|
277
467
|
<!-- Or inline, scoped to one tree -->
|
|
278
|
-
<ASkeleton :loading style="--ak-
|
|
468
|
+
<ASkeleton :loading style="--ak-skel-base: hotpink; --ak-skel-radius: 9999px;">
|
|
279
469
|
<UserCard />
|
|
280
470
|
</ASkeleton>
|
|
281
471
|
```
|
|
282
472
|
|
|
283
|
-
###
|
|
473
|
+
### Multi-tenant + dark mode
|
|
474
|
+
|
|
475
|
+
Light / dark is driven entirely by the `--ak-skel-*` tokens. Three strategies, all built in:
|
|
284
476
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
| `--ak-skeleton-block-soft` | `hsl(var(--ak-ui-muted) / 0.32)` | Fade colour (bottom of the vertical gradient). |
|
|
289
|
-
| `--ak-skeleton-shimmer` | `hsl(var(--ak-ui-foreground) / 0.08)` | Moving-highlight colour of the shimmer sweep. |
|
|
290
|
-
| `--ak-skeleton-radius` | `0.375rem` | Default border radius for blocks (text bars use 60% of this). |
|
|
291
|
-
| `--ak-skeleton-duration` | `1.6s` | Animation duration (shimmer + pulse). |
|
|
292
|
-
| `--ak-skeleton-pulse-opacity` | `0.48` | Min opacity at the trough of the pulse cycle. |
|
|
293
|
-
| `--ak-skeleton-shimmer-angle` | `110deg` | Sweep angle of the shimmer pseudo-element (90deg = horizontal). |
|
|
294
|
-
| `--ak-skeleton-ring` | `hsl(var(--ak-ui-foreground) / 0.04)` | 1px inset ring that gives blocks definition without a border. |
|
|
477
|
+
- **`.dark` class scope** — apply `.dark` to any ancestor (Tailwind / shadcn / `nuxt-color-mode` convention). The package ships `:where(.dark) { --ak-skel-base: hsl(220 13% 22%) … }` so dark tokens kick in automatically.
|
|
478
|
+
- **`@media (prefers-color-scheme: dark)`** — falls back to OS preference when no explicit `.dark` / `.light` class is on an ancestor.
|
|
479
|
+
- **Explicit `.light` class** — wins back light tokens (consumer override).
|
|
295
480
|
|
|
296
|
-
|
|
481
|
+
Multi-tenant CSS can override per scope by setting the variables under a wrapper class. Variant-specific overrides also work — e.g. `<ASkeletonImage style="--ak-skel-base: hsl(200 50% 90%)">` retints one instance.
|
|
297
482
|
|
|
298
483
|
---
|
|
299
484
|
|
|
300
|
-
##
|
|
485
|
+
## Animations
|
|
301
486
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
487
|
+
| Value | What | Default? |
|
|
488
|
+
| --------- | ------------------------------------------------------------------ | -------- |
|
|
489
|
+
| `shimmer` | gradient sweep ::after, contained per block via `overflow: hidden` | yes |
|
|
490
|
+
| `pulse` | opacity 1 → `--ak-skel-pulse-min` → 1 over `--ak-skel-duration` | — |
|
|
491
|
+
| `wave` | gradient via `background-position` (sliding) | — |
|
|
492
|
+
| `none` | static blocks | — |
|
|
493
|
+
|
|
494
|
+
`prefers-reduced-motion: reduce` disables every animation automatically (`animation: none !important` + the shimmer pseudo-element drops to a static low-opacity overlay).
|
|
495
|
+
|
|
496
|
+
---
|
|
497
|
+
|
|
498
|
+
## Accessibility
|
|
499
|
+
|
|
500
|
+
- Every wrapper / layer / variant root carries `role="status"` while loading.
|
|
501
|
+
- `aria-busy="true"` mirrors the loading state.
|
|
502
|
+
- `aria-live="polite"` so screen readers announce the loading state without interrupting the user.
|
|
503
|
+
- A visually-hidden `<span class="a-skel-sr-only">Loading…</span>` ships with every variant primitive so the loading state is read out by screen readers.
|
|
504
|
+
- Every emitted shimmer surface carries `aria-hidden="true"` — the placeholder is decorative; the announcement is the wrapper's job.
|
|
505
|
+
- Mirror-mode skeletons disable `user-select` and `pointer-events` on the slot tree so the placeholder can't be interacted with mid-load.
|
|
506
|
+
- `prefers-reduced-motion` strips animation.
|
|
507
|
+
|
|
508
|
+
---
|
|
305
509
|
|
|
306
510
|
## Performance
|
|
307
511
|
|
|
308
|
-
Designed for components with hundreds of leaf elements
|
|
512
|
+
Designed for components with hundreds of leaf elements — busy dashboards, long lists, dense forms.
|
|
513
|
+
|
|
514
|
+
- **Walk budget** — every walker (`walkDom`, `walkStructural`, `captureSnapshot`) enforces `maxNodes` (defaults 500 / 500 / 800) and reports `truncated: true`. A 5 000-row table will not lock up the main thread. `<ASkeleton>` logs a one-time `console.warn` per `cacheKey` whenever a capture truncates so missing nodes surface during development.
|
|
515
|
+
- **Min-size filter** — `minSize` (default 4 px) drops hairlines / spacer dots.
|
|
516
|
+
- **One-layout reads** — `getBoundingClientRect()` + `getComputedStyle()` happen in a single top-down pass with no intervening writes. One layout up front, then cached values for the rest of the walk.
|
|
517
|
+
- **Allocation-free render** — captured nodes carry frozen pre-computed styles. The render loop reads them directly with no per-node function calls.
|
|
518
|
+
- **Debounced re-capture** — initial measurement via `requestAnimationFrame`; subsequent `ResizeObserver` callbacks debounced 150 ms so a drag-resize doesn't trigger a re-walk per frame.
|
|
519
|
+
- **CSS containment** — `.a-skeleton[data-loading]` uses `overflow: clip` + `contain: paint`, so shadows / filters / transforms can't bleed outside the box.
|
|
520
|
+
- **Composited shimmer** — only `transform: translateX(...)` changes each frame on the shimmer pseudo-element, with `will-change: transform` lifting blocks to their own compositor layer up front.
|
|
309
521
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
-
|
|
315
|
-
-
|
|
316
|
-
-
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## SSR
|
|
525
|
+
|
|
526
|
+
- **`mode="mirror"`** is fully SSR-safe — the walker runs on vnodes, no `window` access required.
|
|
527
|
+
- **`mode="clone"`** is client-side only (needs `getComputedStyle()` + a real DOM). The wrapper renders the slot's normal markup on the server; the snapshot + replay kick in on hydration.
|
|
528
|
+
- **`useSkeleton()`** runs in `onMounted` and bails out cleanly when `window` is undefined.
|
|
529
|
+
|
|
530
|
+
If you need a server-rendered placeholder before hydration finishes, use `mode="mirror"` or hand-craft with `<ASkeletonBlock>` / variant primitives.
|
|
531
|
+
|
|
532
|
+
---
|
|
317
533
|
|
|
318
|
-
##
|
|
534
|
+
## TypeScript
|
|
535
|
+
|
|
536
|
+
Import the public types from the main entry:
|
|
537
|
+
|
|
538
|
+
```ts
|
|
539
|
+
import type {
|
|
540
|
+
ASkeletonProps,
|
|
541
|
+
ASkeletonSlots,
|
|
542
|
+
ASkeletonLayerProps,
|
|
543
|
+
ASkeletonBlockProps,
|
|
544
|
+
CachedShape,
|
|
545
|
+
ShapeNode,
|
|
546
|
+
ShapeNodeType,
|
|
547
|
+
StructuralShape,
|
|
548
|
+
StructuralNode,
|
|
549
|
+
ContainerNode,
|
|
550
|
+
LeafNode,
|
|
551
|
+
LeafKind,
|
|
552
|
+
CaptureSnapshot,
|
|
553
|
+
CapturedNode,
|
|
554
|
+
UseSkeletonOptions,
|
|
555
|
+
UseSkeletonReturn,
|
|
556
|
+
ShapeProbeOptions,
|
|
557
|
+
CaptureStrategy,
|
|
558
|
+
WalkOptions,
|
|
559
|
+
WalkStructuralOptions,
|
|
560
|
+
CaptureOptions,
|
|
561
|
+
BuildOptions,
|
|
562
|
+
SkeletonAnimation,
|
|
563
|
+
SkeletonFallback,
|
|
564
|
+
} from '@alikhalilll/a-skeleton';
|
|
565
|
+
```
|
|
319
566
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
567
|
+
Slot prop types are inferable in templates:
|
|
568
|
+
|
|
569
|
+
```vue
|
|
570
|
+
<ASkeleton #fallback>…</ASkeleton>
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## Browser support
|
|
576
|
+
|
|
577
|
+
Modern evergreen browsers — last two versions of Chrome, Edge, Firefox, Safari, and the matching mobile WebViews. Uses `Range.getClientRects()` (universal since 2017), `ResizeObserver` (universal since 2020), and CSS `contain: paint` (universal since 2022). No polyfills required.
|
|
578
|
+
|
|
579
|
+
The `overflow: clip` containment falls back to `overflow: hidden` on older browsers via the standard CSS cascade — no JavaScript fallback needed.
|
|
580
|
+
|
|
581
|
+
---
|
|
582
|
+
|
|
583
|
+
## Troubleshooting
|
|
584
|
+
|
|
585
|
+
**The skeleton is blank.**
|
|
586
|
+
Make sure your slot's tags are rendered unconditionally. If the slot is `<div v-if="data">…</div>`, the walker sees one comment during loading and falls back to a generic shimmer. Gate **content** per leaf via `{{ data?.field }}`, not the entire template on `v-if`.
|
|
587
|
+
|
|
588
|
+
**An empty `<h3>{{ data?.name }}</h3>` doesn't shimmer.**
|
|
589
|
+
This was the v1 walker's behaviour. v2+ classifies empty text-owner tags (`<h1>`-`<h6>`, `<p>`, `<span>`, …) as text bars rather than generic blocks, so the heading shimmers at its natural rendered height. Make sure you're on the latest version.
|
|
590
|
+
|
|
591
|
+
**My `<button>` loses its background colour.**
|
|
592
|
+
Mirror mode keeps real `bg-*` classes — the engine detects an explicit background and skips the skeleton fallback. If your button has no `bg-*` class and no inline `background`, the walker assumes you want the default skeleton fill. Either add `bg-emerald-600` (or whatever) explicitly, or wrap the inner text in `<ASkeletonBlock>` and let the real `<button>` render around it.
|
|
593
|
+
|
|
594
|
+
**The clone-mode replay drifts after a viewport resize.**
|
|
595
|
+
Clone mode captures absolute coordinates at the moment of snapshot. Resize-aware skeletons should use the structural strategy (`useSkeleton()` + `<ASkeletonLayer>`) — captured containers preserve their flex/grid layout and reflow with the parent.
|
|
596
|
+
|
|
597
|
+
**The structural-mode skeleton looks like a single block, not a tree.**
|
|
598
|
+
The cache may be stale from an older version. `localStorage` entries with schema version `v: 2` (from `walkDom`-flat-model) won't load as `v: 3` structural shapes. Call `clearCachedStructural()` once after upgrading, or wait for the auto-purge on next read.
|
|
599
|
+
|
|
600
|
+
**A decorative SVG / background image renders as a giant skeleton block.**
|
|
601
|
+
Mark it with `data-skeleton-ignore` so the walker treats it as invisible. The slot's chrome (gradients, decorative shapes) should always render verbatim; the skeleton is for **content**.
|
|
602
|
+
|
|
603
|
+
**The structural skeleton's flex/grid layout breaks when rendered in a different CSS context.**
|
|
604
|
+
Containers preserve both the original `class` (for utility CSS) and the resolved layout CSS as inline `style`. If your styles aren't present at the mount point, the inline `display: flex; flex-direction: column; gap: 16px;` fallback still drives the layout.
|
|
605
|
+
|
|
606
|
+
**TypeScript can't find `StructuralShape` / `walkStructural`.**
|
|
607
|
+
These are exported from the main entry from v2.0+. Re-run `pnpm install`, restart your TS server, and confirm the package version in `node_modules/@alikhalilll/a-skeleton/package.json` matches what's in your `package.json`.
|
|
608
|
+
|
|
609
|
+
---
|
|
324
610
|
|
|
325
611
|
## License
|
|
326
612
|
|
|
327
|
-
MIT © alikhalilll
|
|
613
|
+
[MIT](./LICENSE) © alikhalilll
|