# text-skeleton

Typographically accurate text-skeletons for loaders and UI libraries.

A `<text-skeleton>` custom element: a loading placeholder for text that flows
like a real paragraph instead of showing a generic gray rectangle. Reads the
font actually in effect — ascent, x-height, descenders, line-height — instead
of guessing at a fixed height. The folder also has a second, composed
component — `<text-censor>` — for the opposite direction: hiding real text
that's already in the DOM. See
[`<text-censor>` — censoring real content](#text-censor--censoring-real-content)
below.

No dependencies, no build step, ~12 KB gzipped
(`text-skeleton.js`) / ~4 KB (`text-censor.js`).

**[Live demo](demo.html)** · **[Censor demo](censor-demo.html)** · MIT licensed

![Real content next to its <text-skeleton> placeholder — a card with a title, subtitle, two-line body, and footer actions, both sides matching in height and wrap, with variance/roundness/intensity/connect/animation controls below](screenshot.webp)

## Quick start

```html
<!-- copy the file(s) into your project, or load from GitHub via jsDelivr — no npm publish required: -->
<script src="https://cdn.jsdelivr.net/gh/Syneris/text-skeleton@TAG/text-skeleton.js"></script>

<text-skeleton lines="3" connect="word" animated="sweep"></text-skeleton>
<text-skeleton inline chars="12" connect="word" animated="sweep"></text-skeleton>
<text-skeleton text="Same word count, same wrap as this exact string." connect="word" animated="sweep"></text-skeleton>
```

Replace `@TAG` with a release tag (e.g. `@v0.1.0`) once one exists — pinning
to a tag means the file never changes under you; `@main`/`@master` floats
with the branch, fine for prototyping, not for anything you'd ship.
`text-censor.js` is served the same way, at
`.../text-skeleton@TAG/text-censor.js`.

**Using GitHub as a CDN:** [jsDelivr](https://www.jsdelivr.com/) serves any
public GitHub repo directly — `cdn.jsdelivr.net/gh/USER/REPO@TAG/path` — with
no npm publish step and its own edge caching. That's different from pointing
at `raw.githubusercontent.com`, which isn't meant for production traffic
(no real caching, wrong `Content-Type` in some cases) — use the jsDelivr
`/gh/` path, not raw GitHub URLs. If this ever gets `npm publish`ed too,
the same jsDelivr URL scheme works via `cdn.jsdelivr.net/npm/text-skeleton@TAG`
instead, and [unpkg](https://unpkg.com/) becomes an option (unpkg serves only
from npm, not arbitrary GitHub repos).

Open `demo.html` directly in a browser to see everything above in context (no
build step, no dependencies).

## Why this is different from a typical skeleton block

Most text skeletons are one gray `<div>` sized to `1em` or `0.7em` tall. That
gets the height roughly right but throws away everything that makes a block
of text *look* like text:

- real paragraphs wrap into multiple lines, with the **last line shorter**
- every line has consistent baseline-to-baseline spacing (`line-height`)
- individual glyphs vary — most sit in the x-height band, some are tall
  (ascenders/capitals), some hang below the baseline (descenders: g, j, p, q, y)

`<text-skeleton>` reproduces all three, using the font that would actually be
applied to real text at that point in the DOM — not a guess.

## How the font is read (the "bonus" part)

The component never sets its own `font-family` or `font-size`. It inherits
whatever font is in effect at its position in the DOM (same as a real `<p>`
would), then on connect (and on resize / after webfonts finish loading via
`document.fonts.ready`) it:

1. Reads `getComputedStyle(this)` for the resolved `font-style`, `font-weight`,
   `font-size`, `font-family`, and `line-height`.
2. Feeds that exact font string into an offscreen `<canvas>` 2D context and
   uses `measureText()` to get real metrics for *this* font at *this* size:
   - `fontBoundingBoxAscent` / `fontBoundingBoxDescent` — full ascent/descent
   - `actualBoundingBoxAscent` of `"HTKZEBM"` — cap height
   - `actualBoundingBoxAscent` of `"xznomuvw"` — x-height
   - `actualBoundingBoxDescent` of `"gjpqy"` — descender depth
   - width of `"abcdefghijklmnopqrstuvwxyz"` / 26 — average glyph advance
   - width of `" "` — space width
3. Resolves `line-height` to a pixel value the same way the CSS spec does
   (`normal` → `1.2 × font-size`; unitless number → `number × font-size`;
   length → used as-is).

Those numbers directly drive the geometry of every blob, so a bold 28px
heading gets taller, chunkier blobs with more line spacing than a 13px
caption, without any manual tuning per size.

## Layout model

- Word/character positions are always computed left-to-right — there's no
  bidi-aware layout here. `.ts-container` forces `direction: ltr` explicitly
  (one level below `:host`, so the host's own computed `direction` is still
  readable for the `sweep` mode's travel direction) so a `direction: rtl`
  host doesn't make the browser reorder `.ts-word`'s normal inline flow —
  that would desync the rendered position from the JS-computed `--char-x`
  coordinates every character's mask/sweep math depends on.
- Each **line** is a block `div` with height = the real resolved line-height.
- Each **word** is an `inline-block` span with `vertical-align: baseline`
  and no in-flow content, so per the CSS baseline algorithm its bottom
  margin edge *is* the text baseline.
- Each **character blob** inside a word is `position: absolute; bottom: 0`
  (or a negative `bottom` to hang below the baseline for descenders), sized
  from the real font metrics above. This is the same trick that makes real
  descenders not affect line-height — they're free to hang into the leading
  space that already exists for that reason.
- Each character occupies a **slot** whose width is the character's real
  advance width; the *rendered* blob is inset to 82% of that slot and
  centered inside it. The gap between blobs is carved out of each slot
  rather than added between slots — a word's total width always equals the
  sum of its real slot widths, which matters for `text` mode (below): the
  line-wrap decision has to use the real width, not width-plus-cosmetic-gaps.
- Word wrapping and the shorter last line, in the default (no `text`) mode,
  aren't done by the browser's own text-wrapping — they're simulated in JS
  (greedy fill against the container's actual pixel width), which avoids
  forced synchronous layout and lets the component recompute cheaply on
  resize via `ResizeObserver`.

Words themselves come from one of two sources:

- **No `text` attribute (default):** word length and per-character shape
  (x-height / ascender / descender / both) are invented by a seeded PRNG,
  shaped by `variance`. Character *widths* are still randomized around the
  font's average glyph width.
- **`text="…"` given:** rather than approximate how the real string would
  wrap, the component asks the browser directly. It renders the real words
  into a hidden, off-screen probe at the exact font/width in play, reads
  back which line each word actually landed on and how wide it actually
  rendered (via `getBoundingClientRect()`), then builds the skeleton from
  that ground truth — so it wraps to the *same* line count as the real
  paragraph, not merely a close approximation of it (verified in `demo.html`
  from 350px to 1920px). This is the one place the component accepts a
  forced synchronous layout, since `text` mode is an explicit, less-frequent
  opt-in rather than the common case. Each character's shape still comes
  from what that literal letter is (`b/d/h/k/l/t`, digits and most
  punctuation → ascender; `g/j/p/q/y/,/;` → descender; everything else →
  x-height). `variance` and `fill` have no effect in this mode.

## Animation modes

Controlled by the `animated` attribute:

- **`pulse`** (default) — every blob shimmers in sync, same as the classic effect. The
  animated stop *dips* toward transparent rather than peaking toward opaque — a peak
  runs out of headroom once `--skeleton-intensity` reaches 100% (`color-mix()` just
  clamps, and the shimmer goes flat), but a dip toward transparent always has room to
  move regardless of how opaque the base color is.
- **`sweep`** — a dip travels across the whole block (in reading direction — right-to-
  left under `direction: rtl`), as if a light source were passing over it. Implemented
  as a `mask-image`, not a second color layer: layering a brighter color on top of the
  existing `background-color` can only ever *add* coverage, hitting the same 100%
  ceiling a peak does, but a mask multiplies the alpha that's already there, which can
  always go lower. The mask has to be sized well beyond the dip's own width — at least
  double the full travel distance, centered on the dip — because a mask image smaller
  than what it's covering defaults the uncovered area to fully hidden (mask alpha 0),
  not "no effect"; sizing it too tight silently made whole characters disappear
  wherever the mask's own edge fell short of the line's start/end. All `sweep`
  instances on a page share one `requestAnimationFrame` loop (not one timer per
  instance): each character's mask position is sampled at `--ts-sweep-x - --char-x`,
  where `--char-x` (each blob's position within its own line) is set once at render
  time and `--ts-sweep-x` is the one shared, currently-animating position.
- **`typing`** — blobs appear left-to-right/line-by-line like text being typed, hold,
  erase in the same order, then repeat. Pure CSS: each character gets an
  `animation-delay` of `index * --skeleton-typing-speed` and the same
  `animation-duration` (`--ts-typing-cycle`, sized to the total character count), so
  the stagger comes from `animation-delay` alone — no JS ticking.
- **`false`** / **`none`** — static, no animation.

All modes fall back to static blobs under `prefers-reduced-motion: reduce`.

## Connected characters

Separate character blobs read as a dashed line rather than continuous ink.
The `connect` attribute fills the gaps with an x-height bar rendered behind
the characters — flat x-height blobs become redundant once it's there and
are skipped entirely (fewer DOM nodes), while ascender/descender/both blobs
still render as visible "pokes" above/below the bar.

- **`none`** (default) — the original dashed-blob look.
- **`word`** — one bar per word. Inter-word gaps stay empty, so word
  count/rhythm is still legible.
- **`line`** — one bar spanning the whole line, bridging the inter-word gaps
  too. No word boundaries left — reads closer to a classic redaction bar.

The connector is just another `.ts-char` element sized to x-height and
inserted before the real character blobs in DOM order (so it paints behind
them, no `z-index` needed), which means it automatically participates in
whichever `animated` mode is active. The `line` connector is the only piece
of the layout that needs the CSS half-leading formula explicitly (word/char
connectors get baseline alignment for free from `vertical-align: baseline`,
but the line connector is positioned directly inside `.ts-line`, which has
no baseline of its own to inherit).

**No transparency stacking.** The default colors are semi-transparent
(`rgba(...)`) so the skeleton adapts to any background without per-theme
config. That's fine when blobs never overlap — but a poke drawn at its full
height would re-cover the exact band the connector already painted, and two
stacked translucent layers of the same color composite darker than one (the
standard alpha double-coverage artifact). Rather than mask that overlap away
or force opaque colors, the poke is never drawn there in the first place:
each character contributes only the piece(s) of itself that fall *outside*
the connector's `[0, xHeight]` band — a "cap" above it for ascenders, a
"tail" below the baseline for descenders, both for `both`, nothing for a
flat x-height char (which is the same geometry that drives the "skip
redundant flat blobs" optimization above — one check does both jobs). Since
the connector and its pokes are now geometrically disjoint, there's exactly
one paint per pixel and colors stay uniform. The pieces also only round
their outward-facing corners (`border-radius` on the top edge for a cap, the
bottom edge for a tail) so they sit flush against the connector's flat edge
instead of leaving a rounded notch where the two should visually meet.

**No corner collisions at word/line edges.** A connector is a rounded pill.
If the *first* or *last* character of a word (or line, in `connect="line"`)
has a cap or tail poke, that poke sits right on top of the connector's own
rounded corner — most commonly triggered by capital letters (ascenders)
starting a word, which is most words. Rather than bias word/character
generation to avoid ascenders/descenders at edges (unnatural-looking, and
impossible in `text` mode anyway since shapes come from real letters), the
connector's own corner is squared off exactly where a poke would collide
with it — independently per corner (top-left/top-right for a cap at the
first/last character, bottom-left/bottom-right for a tail), so a word like
"Frontend" gets a square top-left (F) *and* a square top-right (d), while a
plain lowercase word keeps all four corners rounded.

**No gap at the run's edges either.** Every character sits inset within its
own slot — the visual gap between blobs is carved out of each slot, not
added between them (see `CHAR_INSET` above) — which is invisible with no
connector, since there's no reference edge to compare against. Once a
connector draws a hard edge at the word's or line's true boundary, that
per-character inset becomes a visible gap between the edge and the first/last
character's ink (worse once that corner is squared, since a flat corner
makes the mismatch obvious rather than softened by a curve). Only the very
first and last character *of the connected run* has its rendered piece
stretched out to the true edge instead of stopping at its own inset boundary
— purely a rendering adjustment, it doesn't touch the slot widths that drive
wrapping or `text` mode's accuracy.

## Usage

```html
<script src="text-skeleton.js"></script>

<!-- block, like a <p> -->
<text-skeleton lines="3"></text-skeleton>

<!-- inline, like a short label mid-sentence -->
<text-skeleton inline chars="12"></text-skeleton>

<!-- reproducible pattern (same seed -> same blobs) -->
<text-skeleton lines="2" seed="42"></text-skeleton>

<!-- force how full the last line is (0..1) -->
<text-skeleton lines="4" fill="0.5"></text-skeleton>

<!-- apples-to-apples: same real string drives word lengths, per-char shape and glyph widths -->
<text-skeleton text="The quick brown fox jumps over the lazy dog."></text-skeleton>

<!-- less "busy" invented shapes than real English (this is also the default) -->
<text-skeleton lines="2" variance="0.2"></text-skeleton>

<!-- animation modes -->
<text-skeleton lines="3" animated="sweep"></text-skeleton>
<text-skeleton lines="3" animated="typing"></text-skeleton>
<text-skeleton lines="3" animated="false"></text-skeleton>

<!-- connected characters instead of a dashed line -->
<text-skeleton lines="3" connect="word"></text-skeleton>
<text-skeleton lines="3" connect="line"></text-skeleton>

<!-- rounder blobs -->
<text-skeleton lines="2" radius="8px"></text-skeleton>

<!-- stronger color tint (scales base + highlight together) -->
<text-skeleton lines="2" intensity="20%"></text-skeleton>
```

### Attributes

| Attribute  | Default              | Meaning                                                              |
| ---------- | --------------------- | ---------------------------------------------------------------------|
| `lines`    | `1` (no cap when `text` is set) | number of lines to render. Without `text`, an exact count. With `text`, an optional cap — omit it to render as many lines as the real string actually wraps into. |
| `fill`     | randomized per instance | 0–1, how full the last (or only) line is. Ignored when `text` is set — the real wrap decides. |
| `text`     | —                     | a real string. Word lengths, per-character ascender/x/descender shape, and glyph widths are all derived from it, so the skeleton wraps to the same line count as the real text at the same width — useful for diffing a real paragraph against its placeholder apples-to-apples. |
| `variance` | `0.2`                 | 0–1, only affects *invented* blobs (i.e. no `text`). How much character shape varies between x-height/ascender/descender/both. `0` ≈ nearly all flat x-height blobs (how skimmed prose reads to the eye); `1` ≈ the real letter-shape frequency of English (busier-looking). |
| `seed`     | random                | integer seed for the invented blob pattern — set it for a stable/reproducible look |
| `inline`   | off                   | presence attribute; switches the host to `inline-block`, sized via `chars` |
| `chars`    | `10`                  | only with `inline` and no `text` — approx. character count, converted to px using real average glyph width |
| `animated` | `pulse`               | `pulse` \| `sweep` \| `typing` \| `false`/`none` — see [Animation modes](#animation-modes) |
| `connect`  | `none`                | `none` \| `word` \| `line` — see [Connected characters](#connected-characters) |
| `radius`   | —                     | any CSS length (`"4px"`, `"0.3em"`, `"50%"`) — convenience attribute for `--skeleton-radius`; omit it and set the CSS custom property directly if you prefer |
| `intensity` | —                    | any CSS percentage (`"9%"`) — convenience attribute for `--skeleton-intensity`; no effect once `--skeleton-base`/`--skeleton-highlight` are set explicitly |

### CSS custom properties

| Property                  | Default                                                 | Used by            |
| -------------------------- | -------------------------------------------------------- | ------------------- |
| `--skeleton-base`          | `color-mix(in srgb, currentColor var(--skeleton-intensity, 9%), transparent)` | all modes |
| `--skeleton-highlight`     | `color-mix(in srgb, currentColor calc(var(--skeleton-intensity, 9%) * 0.35), transparent)` | `pulse`'s dip |
| `--skeleton-sweep-dip`     | `35%`                                                      | `sweep`'s mask alpha at the dip — a fraction of whatever `--skeleton-base` currently renders as, not an absolute color, so it needs no separate intensity scaling |
| `--skeleton-intensity`     | `9%`                                                       | scales `--skeleton-base`/`--skeleton-highlight` together, preserving their ratio |
| `--skeleton-duration`      | `1.6s`                                                    | `pulse`; scaled ×1.5 for `sweep`'s travel time |
| `--skeleton-typing-speed`  | `45ms`                                                    | `typing` — ms per character |
| `--skeleton-radius`        | `0.25em`                                                  | all modes            |

**Color adapts to context, not a fixed gray.** Since `:host { color: inherit }`
was already in place (needed so `getComputedStyle` reads the real ambient
font), `currentColor` inside the shadow DOM already resolves to whatever text
color is actually in effect at the skeleton's position — no extra plumbing
required. So the default background tints from *that* color at low opacity
rather than a hardcoded neutral gray. Two consequences: a skeleton dropped
into a dark button with white text renders light automatically (no manual
`--skeleton-base` override needed — see the "Apply now" button in the card
demo), and a skeleton inside a colored link picks up a faint tint of that
color too. For the common case — dark, near-neutral body text — this is
visually indistinguishable from a fixed gray, since `color-mix(currentColor
9%, transparent)` against near-black *is* essentially `rgba(0, 0, 0, 0.09)`.
`color-mix()` needs a 2023-era browser (Chrome 111+/Firefox 113+/Safari
16.2+); unsupported browsers silently drop the declaration and fall back to
no background at all. That's an acceptable trade-off for a loading
placeholder specifically — set `--skeleton-base`/`--skeleton-highlight`
explicitly (as in "Custom colors via CSS variables" below) if you need a
guaranteed-visible fallback.

How strongly that tint shows is `--skeleton-intensity` (or the `intensity`
attribute) — a single percentage that `--skeleton-base`/`--skeleton-highlight`
are both derived from via `calc()`, so raising it scales the whole shimmer up
together rather than needing two separate values kept in sync. It only
feeds the *default* color-mix expressions, so it has no effect once you set
`--skeleton-base`/`--skeleton-highlight` explicitly.

### Method

`el.regenerate()` — reroll the random blob pattern in place (keeps the same
`lines`/`fill`/font, just picks new word lengths and character shapes). No
effect when `text` is set — there's nothing random left to reroll.

## `<text-censor>` — censoring real content

A second, separate component (`text-censor.js`) for a different job: hiding
content that's *already there* rather than standing in for content you don't
have yet. Think redacted screenshots, screen recordings, or "blur sensitive
fields" UI. It composes `<text-skeleton>` rather than duplicating any of its
rendering logic — it just walks real markup, extracts the real text, and
generates matching `<text-skeleton text="…">` placeholders. Unlike
`<text-skeleton>`'s own default (invented blobs), it never falls back to
random generation — there's no `variance`/`seed` here, since the whole
premise is that the real text is already known.

```html
<script src="text-skeleton.js"></script>
<script src="text-censor.js"></script>

<text-censor id="c" connect="word" animated="pulse">
  <h3>Senior Frontend Engineer</h3>
  <p>We're looking for an experienced engineer…</p>
  <button><svg data-censor-skip>…</svg> Apply now</button>
</text-censor>

<button onclick="c.toggleAttribute('active')">Toggle</button>
```

Open `censor-demo.html` for a much larger stress test — a full marketing-page
layout (nav, hero, a feature grid, a pricing `<table>`, a testimonial
`<blockquote>`, a `<details>` FAQ accordion, and a contact form), with only
the main content wrapped so the nav/footer demonstrate the wrapper's
boundary. It's what surfaced most of the edge cases below; `demo.html`'s own
card-sized example is deliberately small and doesn't exercise nearly as much.

- No shadow DOM — the real markup stays in the light DOM untouched (so it
  still renders normally if this script never loads), and the host is set to
  `display: contents` so `<text-censor>` never affects layout itself; put
  visual styling (border, padding, background) on a wrapping element instead,
  since a `display: contents` box can't render its own.
- On connect, it walks its children for "text leaves" — elements whose
  content isn't further subdivided by nested block-level structure (a `<p>`,
  or a `<button>`'s label; a `<div>` containing both a `<h3>` and a `<p>`
  gets recursed into instead, each handled separately). Each leaf's real
  children move into a marker span, and a matching `<text-skeleton text="…">`
  is inserted alongside it — `inline`+`chars`-sized for naturally inline
  leaves (links, button labels), block with no `lines` cap for everything
  else, so it wraps to exactly as many lines as the real text does.
- `active` (presence attribute) toggles which of the pair is visible; the
  DOM walk itself only happens once — call `.refresh()` to re-scan after
  adding new content.
- `data-censor-skip` on any element excludes it from being scanned as a
  target, and — if it's inside a leaf that *is* being censored, like an icon
  next to a button label — keeps it in place and visible either way.
- `connect`/`animated`/`radius`/`intensity` are passed through to every
  generated `<text-skeleton>`, so setting them once on `<text-censor>`
  configures the whole subtree — a concrete instance of "configure many
  instances at once" scoped to what the wrapper owns, versus a page-wide
  `:root { --skeleton-... }` override.

### Gotchas this hit during development

- **Constructors must not add attributes.** `this.style.display = 'contents'`
  in the constructor throws `NotSupportedError: The result must not have
  attributes` — setting a CSSOM style property reflects into the `style=""`
  attribute, and the Custom Elements spec forbids a constructor from causing
  the element to gain any attributes. Moved to `connectedCallback` instead.
- **A parser-inserted element's `connectedCallback` fires before its own
  children exist.** For markup like `<text-censor><h3>…</h3></text-censor>`,
  the opening tag gets upgraded (and `connectedCallback` invoked) as soon as
  it's inserted — which, for a parser processing an uninterrupted document,
  can happen before it reaches the closing tag. Walking `this.children`
  synchronously in `connectedCallback` found nothing. A `queueMicrotask`
  wasn't late enough either — the parser can run the rest of the document
  through to completion before the next microtask checkpoint. Deferring with
  `requestAnimationFrame` (the same technique `<text-skeleton>` already uses
  for its own render scheduling) reliably lands after parsing finishes.
- **Computed `display` alone can't drive the inline-vs-block choice.** A flex
  item's computed `display` is *blockified* to `block` per spec regardless of
  the element's own default — so a `<button>` inside any `display: flex` row
  (an extremely common pattern) reported `display: block`, got sized as a
  wrapping paragraph instead of a content-sized label, and wrapped its text
  onto a second line. `<summary>` has the same problem from the other
  direction: its default computed display is `list-item`, never `inline`, so
  a naive `display.startsWith('inline')` check missed it even outside any
  flex context. Fixed by classifying a fixed set of conventionally-inline
  tags (`a`, `button`, `label`, `summary`, …) by tag name first, falling back
  to computed display only for everything else.
- **A padded `chars` estimate can't also drive the rendered width.**
  `chars` only needs to be a safe *upper bound* for the wrap probe — the real
  text-mode measurement decides the actual rendered width regardless of how
  generous the bound is. But `<text-skeleton>`'s inline mode used to set its
  own CSS width directly from that estimate (`chars × avgCharWidth`) rather
  than the real measured content width, so padding `chars` for safety also
  visibly bloated the rendered element — enough to push a following inline
  sibling (an icon) onto its own line. Fixed by sizing the host from the
  actual widest rendered line instead.
- **Absolutely positioned content contributes zero intrinsic width.** Every
  blob in `<text-skeleton>` is `position: absolute`, which — same as an
  empty element — is invisible to a browser's intrinsic/auto sizing
  calculations. Real text has substantial intrinsic width and papers over
  this everywhere it would otherwise matter; a censored flex row (fixed-width
  avatar + auto-width text column with no explicit `flex-grow`) doesn't, so
  the text column collapsed toward zero once its only child stopped being
  real text, and the skeleton then wrapped into that collapsed sliver. Not a
  `<text-skeleton>` or `<text-censor>` bug to fix internally — it's the
  standard "flex item needs an explicit `flex-grow` to actually fill
  available space" gotcha, just newly visible because the swapped-in content
  no longer has intrinsic size to fall back on. `censor-demo.html`'s
  testimonial block documents the fix (`flex: 1; min-width: 0` on the text
  column) inline.

## Browser support

Built against current Chrome, Edge, Firefox, and Safari — no polyfills, no
transpilation, no build step. Verification during development was done in
Chromium (via Playwright); Firefox and Safari haven't been separately
checked by a human yet, though nothing here is intentionally Chromium-only.

What each part actually needs:

- **Custom Elements v1 + Shadow DOM** — universal in evergreen browsers for
  years. Not supported in Internet Explorer (not a target).
- **`ResizeObserver`** — universal in evergreen browsers since ~2020.
- **`color-mix()`**, the default adaptive color — Chrome 111+, Firefox 113+,
  Safari 16.4+ (all March–May 2023, [see above](#css-custom-properties) for
  why). Older browsers don't get a muted fallback color; the whole
  declaration is invalid per spec and gets dropped, so the skeleton renders
  with *no* background at all. Set `--skeleton-base`/`--skeleton-highlight`
  to a plain `rgba()`/`hsl()` value yourself to sidestep `color-mix()`
  entirely if you need to support anything older.
- **`mask-image`** (`sweep` animation only) — broad support; both
  `-webkit-mask-image` and the unprefixed property are set.
- **`TextMetrics.actualBoundingBoxAscent`/`actualBoundingBoxDescent`**
  (precise glyph metrics) — degrades gracefully rather than being a hard
  requirement: a browser without them gets ratio-based estimates instead of
  precise measurement, not a thrown error.

## Status

This hasn't been performance- or production-hardened — treat it as an
experiment, not a 1.0, and expect rough edges:

- No automated test suite or CI. Everything was verified manually (visual
  inspection + Playwright screenshots) while building it, not covered by
  regression tests going forward.
- Every character can be its own DOM element (more with `connect`'s
  connector/poke split). Fine for a handful of skeletons on a page; a long
  list with dozens of multi-line skeletons hasn't been benchmarked.
- `text` mode's real-DOM probe (`measureRealWordLines`) forces one
  synchronous layout per render — a deliberate, documented trade-off for
  exact line-count accuracy, worth knowing if you're rendering many
  `text`-mode instances at once (e.g. on every keystroke).
- Decorative content is marked `aria-hidden="true"`, but that's the extent of
  the accessibility work so far — nothing's been tested with a screen reader.
- `<text-censor>`'s leaf-detection heuristic (tag name + computed display)
  has only been run against the two example pages in this repo, not a wide
  sample of real-world markup.

## Notes

- Because it inherits `font`/`color`/`line-height` from its surroundings and
  only ever measures, it's a genuine drop-in replacement — style it exactly
  like you'd style the real text it stands in for, then swap the tag.
