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
<!-- 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).
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.
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:
- Reads
getComputedStyle(this)for the resolvedfont-style,font-weight,font-size,font-family, andline-height. - Feeds that exact font string into an offscreen
<canvas>2D context and usesmeasureText()to get real metrics for this font at this size:fontBoundingBoxAscent/fontBoundingBoxDescent— full ascent/descentactualBoundingBoxAscentof"HTKZEBM"— cap heightactualBoundingBoxAscentof"xznomuvw"— x-heightactualBoundingBoxDescentof"gjpqy"— descender depth- width of
"abcdefghijklmnopqrstuvwxyz"/ 26 — average glyph advance - width of
" "— space width
- Resolves
line-heightto 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.
- Word/character positions are always computed left-to-right — there's no
bidi-aware layout here.
.ts-containerforcesdirection: ltrexplicitly (one level below:host, so the host's own computeddirectionis still readable for thesweepmode's travel direction) so adirection: rtlhost doesn't make the browser reorder.ts-word's normal inline flow — that would desync the rendered position from the JS-computed--char-xcoordinates every character's mask/sweep math depends on. - Each line is a block
divwith height = the real resolved line-height. - Each word is an
inline-blockspan withvertical-align: baselineand 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 negativebottomto 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
textmode (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 viaResizeObserver.
Words themselves come from one of two sources:
- No
textattribute (default): word length and per-character shape (x-height / ascender / descender / both) are invented by a seeded PRNG, shaped byvariance. 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 (viagetBoundingClientRect()), 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 indemo.htmlfrom 350px to 1920px). This is the one place the component accepts a forced synchronous layout, sincetextmode 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).varianceandfillhave no effect in this mode.
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-intensityreaches 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 underdirection: rtl), as if a light source were passing over it. Implemented as amask-image, not a second color layer: layering a brighter color on top of the existingbackground-colorcan 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. Allsweepinstances on a page share onerequestAnimationFrameloop (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-xis 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 ananimation-delayofindex * --skeleton-typing-speedand the sameanimation-duration(--ts-typing-cycle, sized to the total character count), so the stagger comes fromanimation-delayalone — no JS ticking.false/none— static, no animation.
All modes fall back to static blobs under prefers-reduced-motion: reduce.
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.
<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>| 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 |
| 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.
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.
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: contentsso<text-censor>never affects layout itself; put visual styling (border, padding, background) on a wrapping element instead, since adisplay: contentsbox 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 nolinescap 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-skipon 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/intensityare 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.
- Constructors must not add attributes.
this.style.display = 'contents'in the constructor throwsNotSupportedError: The result must not have attributes— setting a CSSOM style property reflects into thestyle=""attribute, and the Custom Elements spec forbids a constructor from causing the element to gain any attributes. Moved toconnectedCallbackinstead. - A parser-inserted element's
connectedCallbackfires before its own children exist. For markup like<text-censor><h3>…</h3></text-censor>, the opening tag gets upgraded (andconnectedCallbackinvoked) as soon as it's inserted — which, for a parser processing an uninterrupted document, can happen before it reaches the closing tag. Walkingthis.childrensynchronously inconnectedCallbackfound nothing. AqueueMicrotaskwasn't late enough either — the parser can run the rest of the document through to completion before the next microtask checkpoint. Deferring withrequestAnimationFrame(the same technique<text-skeleton>already uses for its own render scheduling) reliably lands after parsing finishes. - Computed
displayalone can't drive the inline-vs-block choice. A flex item's computeddisplayis blockified toblockper spec regardless of the element's own default — so a<button>inside anydisplay: flexrow (an extremely common pattern) reporteddisplay: 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 islist-item, neverinline, so a naivedisplay.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
charsestimate can't also drive the rendered width.charsonly 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 paddingcharsfor 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>isposition: 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 explicitflex-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 explicitflex-growto 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: 0on the text column) inline.
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-highlightto a plainrgba()/hsl()value yourself to sidestepcolor-mix()entirely if you need to support anything older.mask-image(sweepanimation only) — broad support; both-webkit-mask-imageand 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.
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. textmode'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 manytext-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.
- Because it inherits
font/color/line-heightfrom 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.
