Skip to content

Repository files navigation

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 below.

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

Live demo · Censor demo · 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

Quick start

<!-- 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 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 published too, the same jsDelivr URL scheme works via cdn.jsdelivr.net/npm/text-skeleton@TAG instead, and unpkg 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 (normal1.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

<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
connect none none | word | line — see 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.

<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 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.

About

Typographically accurate text-skeletons for loaders and UI libraries. Zero-dependency web component — works with any framework or none.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages