skeleton-dither · git:20261023.0a306e3 · 2026-10-23 · sha256 4782009c5f1a2bc8

skeleton-dither git:20261023.0a306e3A

Immutable. This exact content never changes and is served at /api/v1/blob/4782009c5f1a2bc8.

---
name: skeleton-dither
description: Use when building a loading skeleton or a content/image reveal that should feel ASCII or dithered - a shimmering glyph skeleton (░▒▓█) that decodes into the real text and images, for cards, profiles, dashboards and heroes. Based on ascii-reveal by Quan Hoang (MIT).
---

# skeleton-dither

An ASCII loading state in two parts. First, a **dither skeleton**: shaded glyph bars (`░▒▓█`) with a denser band sweeping across them. Then a **decode reveal**: when data lands, every character scrambles into place, and images resolve from coarse glyph cells into real pixels. Both move as one radial wave.

Source: [ascii-reveal](https://quanhoangindex.github.io/ascii-reveal/) by Quan Hoang, from the repo [quanhoangindex/ascii-reveal](https://github.com/quanhoangindex/ascii-reveal) (v0.3.0). It's **MIT licensed**, so copying or adapting it is allowed if you keep the copyright notice. You can also install the original with `npm i github:quanhoangindex/ascii-reveal`; it includes `ascii-reveal/core` for non-React use. The reference file `skeleton-dither.js` in this skill's folder is a clean, smaller rewrite. It credits the original in its header. The folder also has `demo/` (working demo plus `verify.py`) and `shots/` (demo vs original screenshots).

## When to use
- Skeleton or loading states for cards, profiles, dashboards and stats that should feel crafted, not gray.
- Revealing a hero, image or card on mount, on route change, or when it scrolls into view.
- Brands that are techy, editorial or terminal-like. Skip it for dense forms, for anything that needs a calm feel (medical, finance errors), and for content that has to be readable at 0 ms.

## How it works
1. **Skeleton: pure HTML and CSS, painted on the first frame, no JS at runtime.**
   - Each line is 3 stacked `white-space:pre` spans:
     - a faint **base** row (opacity .22),
     - two **wave** rows that use denser glyphs and alternate every 120 ms (`steps(1)` visibility flip), so the glyphs inside the band look like they scramble.
   - A `mask-image` gradient band (55% wide) sweeps from `-120%` to `220%` over 1.8 s with `cubic-bezier(.45,.05,.55,.95)`.
   - Each line is delayed 0.09 s more than the one above, so the sweep reads as one diagonal band.
   - Glyph rows come from a deterministic integer hash of (index, seed), so SSR and client output match.
2. **Reveal: one `<canvas>` overlay above the real DOM.**
   - The real content stays in the DOM at its real position. Nothing shifts, and screen readers read it the whole time.
   - Text is hidden with `-webkit-text-fill-color:transparent`, so `currentColor` icons stay visible.
   - Images, and fades marked with `[data-sd="fade"]`, are hidden through `opacity: var(--sd-o)`.
   - **The wave:**
     - Each target starts at `offset = min(distance(target centre, origin) / 3.2 px/ms, 220 ms)`.
     - The default origin is the centre. It can also be `top-left` or `{x,y}` in viewport px; pass `{x:e.clientX,y:e.clientY}` to start from the pointer.
   - **Text:**
     - Each grapheme is measured with a `Range`.
     - Character k starts at `s0 = k/n·spread·0.72 + rand·spread·0.28`, with `spread = min(200, 40 + 4n)` ms.
     - It scrambles for `90 + rand·110` ms.
     - For the first 70% of that time it **condenses through the ramp** (light to dense, ±1 jitter 35% of the time). After that it picks random glyphs from the scramble set. Glyphs change every `tick` ms and are drawn in the mono font at 50% alpha.
     - Then it **locks**: the real character is drawn in the element's own font. When everything has landed, the overlay is removed and the DOM text is unhidden.
     - Numeric text scrambles through digits only.
   - **Images (450 ms):**
     - The pixels are drawn to an offscreen canvas.
     - The image is split into grids of mono cells (cell width = 0.62 × height), stepping coarse to fine: `[24,16,11,8,6]` px for decode and `[20,14,10,7,5]` for dither. Cells scale down on images smaller than 230 px.
     - Each cell takes the strongest of 5 samples. Its ink is `|lum − backdropLum| / max(bgLum, 1−bgLum)`, and that picks the glyph from the ramp.
     - Each cell appears when `gate < q·2.3`, with `gate = 0.5·noise + 0.5·distance from origin`. This gives an organic radial fill.
     - On each tick, 16% of the cells flicker by ±0.175 ink.
     - The real image cross-fades in from q = 0.68 (easeInOutCubic) while the glyphs fade out from q = 0.74.
   - **Fades (220 ms):** easeOutCubic opacity on the wave.

## Tuned parameters (from the original)
| Preset | ramp | scramble | image color | cells | tick |
|---|---|---|---|---|---|
| decode | ` .:-=+*#%@` | `A–Z 0–9 #%&*+=?/` | text color | 24,16,11,8,6 | 60 ms |
| dither | ` ░▒▓█` | `░▒▓█` | source pixels | 20,14,10,7,5 | 85 ms |
| terminal | ` .-:=+*#` | `01<>/\{}[]\|_=+-;:` | text color, `█` cursor leads | 22,15,10,7,6 | 40 ms |
| blueprint | ` ·-+=x#` | `+-/\|x·` | accent | 26,18,13,9,7 | 95 ms |

| Timing | vel | maxWave | spread | per char | media | fade |
|---|---|---|---|---|---|---|
| fast (data, readable ~400 ms) | 3.2 px/ms | 220 ms | min(200, 40+4n) | 90+110·r | 450 | 220 |
| cinematic (hero) | 1.15 px/ms | ∞ | min(720, 90+13n) | 200+240·r | 1150 | 420 |

Skeleton variants:

| Variant | base | wave | base opacity | wave opacity | band | notes |
|---|---|---|---|---|---|---|
| **dither** | `░░░░▒` | `▒▓▓█▓▒` | .22 | .75 | 55% | `line-height:1`, `letter-spacing:-.02em` |
| scan | `··:·:-` | `+=*#%@&$` | .28 | .95 | 38% | 8% gaps |
| braille | `⠢⠔⠑…` | `⣿⣷⣯…` | .35 | 1 | 40% | |
| terminal | `01` | `01` | .16 | 1 | 35% | 1.5 s sweep, accent color |

Rows are 160 glyphs long and clipped by `overflow:hidden`. Block skeletons (images, avatars) get `rows` lines and a 6% `currentColor` tint background.

Fonts: Geist for UI and Geist Mono for glyphs (`--sd-font`). The demo page background is `#f6f6f7` and the text is `#141416`.

## Implementation steps
1. Copy `skeleton-dither.js` from this skill's folder (about 300 lines, no dependencies) into the project, then call `injectSkeletonCSS()` once.
2. While loading, render `skeletonHTML({ variant:'dither', lines:2, widths:['55%','40%'], seed })` per text block, and `skeletonHTML({ rows: 26 })` inside image boxes. Give the boxes fixed sizes so the skeleton reserves the layout. Use `circle:true` for avatars. Give neighbouring skeletons different `seed`s.
3. When data arrives, swap in the real markup, then `await createReveal(container, { preset:'decode', duration:'fast', origin:'center' }).reveal()`.
4. Mark buttons and chips `data-sd="fade"`. Use `data-sd="skip"` for UI that must stay usable.
5. Theme with `--sd-font`, `--sd-color` (skeleton glyphs) and `--sd-accent` (for the terminal and blueprint looks).

Skeleton markup produced, for SSR or any templating:
```html
<span class="sd sd-dither" data-sd-skeleton aria-hidden="true">
  <span class="sd-line" style="width:55%;--d:0s">
    <span class="sd-base">░░▒░░░…</span><span class="sd-wave">▓█▒▓…</span><span class="sd-wave">▒▓▓█…</span>
  </span>
</span>
```

Core reveal loop (simplified; the full version is in `skeleton-dither.js`):
```js
const t0 = performance.now() + 16;
function frame(now) {
  const local = now - t0, paint = now - lastPaint >= 30;    // glyphs ~30fps, opacity every frame
  if (paint) { lastPaint = now; ctx.clearRect(0, 0, W, H); }
  let done = true;
  for (const t of targets) {
    const lt = clamp(local - t.offset, 0, t.dur);           // offset = radial wave delay
    if (local - t.offset < t.dur) done = false;
    t.draw(lt, paint);                                      // text: ramp→scramble→real; media: glyph grid→pixels
  }
  done ? finish() : requestAnimationFrame(frame);           // finish: remove canvas, unhide DOM
}
```

## React wrapper
```tsx
function DitherReveal({ loading, children, preset = 'decode' }) {
  const ref = useRef<HTMLDivElement>(null);
  const was = useRef(loading);
  useLayoutEffect(() => {           // layout effect: hide before first paint, no flash
    if (was.current && !loading && ref.current) createReveal(ref.current, { preset }).reveal();
    was.current = loading;
  }, [loading]);
  return <div ref={ref} aria-busy={loading}>{children}</div>;
}
// <DitherReveal loading={!data}>{data ? <Profile/> : <Skeleton/>}</DitherReveal>
// Skeleton = <span dangerouslySetInnerHTML={{__html: skeletonHTML({lines:3})}}/>
```
- In Next.js, mark the wrapper `"use client"`.
- Don't animate content that was server-rendered on hydration. It's already readable.
- If you'd rather not maintain your own copy, use the original `ascii-reveal` package (`<AsciiReveal loading>` and `<AsciiSkeleton>`).

## Performance
- Use one canvas per reveal, sized to the animated area, with DPR capped at 2. Precompute each image grid once per cell step and sample pixels once (`willReadFrequently`, OffscreenCanvas).
- Repaint glyphs at about 30 fps, since glyphs only change every 40–95 ms. Opacity cross-fades still update every frame.
- Use one shared rAF loop. Stop the loop as soon as every target has landed, and remove the canvas.
- The original also does these:
  - It bakes landed text onto a second canvas.
  - It only measures the viewport plus 120 px. Sections below the fold are hidden instantly and decode when an IntersectionObserver sees them.
  - A **watchdog** watches frame times. After 8 frames over 45 ms, it finishes the reveal and uses fades for the next 10 s.
  - A **ResizeObserver** finishes the reveal if the root resizes mid-run.
- Wait for `document.fonts.ready` and image `decode()` (900 ms cap) before measuring. Cross-origin images without CORS fall back to a neutral noise texture.
- rAF pauses on hidden tabs automatically. For looping shimmers, also pause them when they're off screen (IntersectionObserver).

## Accessibility
- `prefers-reduced-motion: reduce`:
  - the skeleton sweep is removed (`.sd-wave{display:none}`),
  - the reveal becomes a 220 ms opacity fade,
  - hides are instant.
- The skeleton and the overlay are `aria-hidden="true"`. Put `aria-busy="true"` on the loading region and remove it when the reveal finishes.
- The real text stays in the DOM and in the accessibility tree the whole time. Never put the content only on the canvas.
- Status text such as "all ready in 1.6 s" goes in `aria-live="polite"`.

## Tuning tips
- **Data or UI:** use `fast`. Heroes and replays use `cinematic` or `speed: 0.6`.
- **Origin:** `center` for cards. `top-left` reads like a scan. Use `{x:e.clientX,y:e.clientY}` for click-to-replay.
- **Color images:** use `dither` (source colors). Use `decode` or `blueprint` for a mono or brand look.
- **On a brand color,** set `--sd-color` to a darker tint of the text color. Keep base opacity at .2–.3 so bars read as shapes, not noise.
- **If it looks noisy,** lower the flicker rate (16%) or raise `tick`. If it feels slow, cap `maxWave` lower. Content should be readable in about 400 ms.
- **Fonts:** glyph alignment needs a monospace font with full block and braille coverage (Geist Mono, JetBrains Mono, SF Mono).

## Demo and verification
- The demo is `demo/index.html`, with skeleton/preset toggles and Reload. Serve the skill folder (`python3 -m http.server 8811`) and open `/demo/`.
- `demo/verify.py` (Playwright) runs from `demo/`. It saves screenshots to `../shots/`, checks for console errors, checks that no `data-sd-*` attributes or overlays are left behind, and runs the reduced-motion path.