Ever noticed that tiny “a” dangling at the end of a headline? 😱
text-wrap: pretty
already makes paragraphs look smarter — but it isn’t language-aware. In many editorial traditions, you don’t want to break after minor words (articles, prepositions, conjunctions), or between an honorific and the following name, or between initials and a surname.
This post proposes a small, language-aware upgrade to line breaking — and ships a tiny polyfill you can try in 30 seconds.
TL;DR
text-wrap: pretty
improves line breaks, but it’s not language-aware.- This tiny polyfill adds a thin layer of editorial glue: keeps obvious pairs together (e.g. Mr. Smith, Fig. 2, 20 °C, 1900–2000, J. K. Rowling) and optionally keeps minor words attached to the next word where that’s the editorial norm.
- Zero deps, one DOM pass, no layout measurements.
What pretty
solves — and what it can’t
text-wrap: pretty
focuses on avoiding short last lines and can make smarter break choices near the end of a paragraph (details vary by engine).
But it’s agnostic about editorial semantics — it doesn’t know that Fig. and 2 belong together, or that many editorial traditions avoid breaking after short function words.
This polyfill adds just enough semantics on top of pretty
, without re-implementing layout. Think of it as one layer of “do not break here” for well-established editorial conventions.
The upgrade in one minute
-
Scope: only elements whose computed
text-wrap
ispretty
. - Scan: a single pass over text nodes (no layout reads, no reflows).
-
Decide: match language-aware patterns:
- Safe rules (always on): honorifics + name, initials + surname, label + number, number + unit, § + number, numeric ranges (WORD JOINER around the dash).
- Minor words (per locale): attach short function words only where this is a common editorial convention.
-
Glue: replace a normal space with NBSP (
U+00A0
) or insert WORD JOINER (U+2060
) where appropriate. -
Idempotent & safe: skips URLs/emails, doesn’t cross inline elements by default, doesn’t touch
pre/code/...
.
Quick start
Plain HTML (no build)
<article class="typo" lang="en">
<p>See Fig. 2 for details.</p>
</article>
<style>
.typo { text-wrap: pretty; hyphens: auto; }
/* Older UAs without `text-wrap: pretty`: opt-in to the glue layer */
@supports not (text-wrap: pretty) {
.typo { --text-wrap-preferences: minor-words; }
}
</style>
<script type="module">
import { init, registerLanguage } from 'https://cdn.jsdelivr.net/npm/text-wrap-minor-words@0.3.1/dist/lite.mjs';
import en from 'https://cdn.jsdelivr.net/npm/text-wrap-minor-words@0.3.1/locales/en.json' assert { type: 'json' };
registerLanguage('en', en);
const ctrl = init({ languages: ['en'] });
ctrl.process();
</script>
Swap
en
for another locale if needed. To support multiple locales, import/register more JSON files the same way.
Node / bundler (ESM)
npm i text-wrap-minor-words
import { init, registerLanguage } from 'text-wrap-minor-words/lite';
import en from 'text-wrap-minor-words/locales/en.json';
registerLanguage('en', en);
const ctrl = init({ languages: ['en'], observe: true });
ctrl.process();
<style>
.typo { text-wrap: pretty; hyphens: auto; }
@supports not (text-wrap: pretty) {
.typo { --text-wrap-preferences: minor-words; }
}
</style>
You can also load the non-lite global build:
<script src="https://cdn.jsdelivr.net/npm/text-wrap-minor-words@0.3.1/dist/index.global.js"></script>
(includes built-in locale data for quick trials; prefer lite in production).
English display opt-in (optional)
By default, minor words are off for English body text. This snippet enables them only in display contexts (headings).
Plain HTML (no build)
<article class="typo" lang="en">
<h1>From a book to the browser: a practical guide to typography on the web</h1>
</article>
<style>
.typo { text-wrap: pretty; hyphens: auto; }
/* Enable the minor-words glue only for English headings (display) */
.typo :is(h1,h2,h3,h4,h5,h6):lang(en) {
--text-wrap-preferences: minor-words; /* gate on */
--text-wrap-minor-threshold: 1; /* glue after 1-char tokens */
--text-wrap-minor-stoplist: "of to in on at for by a I"; /* adjust to your style guide */
}
@supports not (text-wrap: pretty) {
.typo { --text-wrap-preferences: minor-words; }
}
</style>
<script type="module">
import { init, registerLanguage } from 'https://cdn.jsdelivr.net/npm/text-wrap-minor-words@0.3.1/dist/lite.mjs';
import en from 'https://cdn.jsdelivr.net/npm/text-wrap-minor-words@0.3.1/locales/en.json' assert { type: 'json' };
registerLanguage('en', en);
const ctrl = init({ languages: ['en'], context: 'display' }); // limit processing to display contexts
ctrl.process();
</script>
Bundler / ESM
npm i text-wrap-minor-words
import { init, registerLanguage } from 'text-wrap-minor-words/lite';
import en from 'text-wrap-minor-words/locales/en.json';
registerLanguage('en', en);
const ctrl = init({ languages: ['en'], context: 'display' });
ctrl.process();
.typo { text-wrap: pretty; hyphens: auto; }
.typo :is(h1,h2,h3,h4,h5,h6):lang(en) {
--text-wrap-preferences: minor-words;
--text-wrap-minor-threshold: 1;
--text-wrap-minor-stoplist: "of to in on at for by a I";
}
@supports not (text-wrap: pretty) {
.typo { --text-wrap-preferences: minor-words; }
}
Tip: prefer
:lang(en)
over[lang="en"]
so headings match the language inherited from the container.
Defaults & language notes (compact)
-
Safe rules (on in every language): honorifics + name (Mr. Smith, Dr. Müller), initials + surname (J. K. Rowling), label + number (Fig. 2, S. 12), number + unit (20 °C, 10 km), § + number, numeric ranges (1900–2000 with
U+2060
around the dash). - Minor words: on by default in Romance/Slavic/Greek; off by default in English/German/Dutch (you can opt-in for display).
Locale | 1-letter glue | Examples from the default stop-list* |
---|---|---|
fr | ✓ | de, du, le, la, les, un, une… |
it | ✓ | di, da, in, su, con, per… |
pl | ✓ | w, z, do, na, po… |
ru | ✓ | в, к, с, на, по… |
el | ✓ | σε, το, τη, οι… |
en | — | (safe rules by default; opt-in for display) |
* Full lists and configuration in the language dataset.
Performance & accessibility
- O(n) over text nodes. Regex pre-compiled per locale. No layout measurements.
- Early exit for neutral locales (e.g. en/de/nl when minor words are off).
- Screen readers: NBSP/WORD JOINER are invisible to AT; URL/email detection prevents accidental glue.
Caveats
-
Inline crossing: currently we don’t cross inline elements (e.g.
a <em>word</em>
). We may evaluate a future opt-in. - CJK/RTL: not targeted for now (different line-breaking traditions).
- Not a grammar rule: this encodes editorial conventions, not linguistic rules. Defaults are conservative; override when your style guide differs.
Spec angle (why this might belong in CSS)
This polyfill explores a small, language-aware extension to text-wrap
. If real-world use proves it broadly useful and low-risk, one possible shape could be a dedicated option (e.g. a “minor-words” sub-mode) that lets UAs apply these low-controversy “do not break here” hints.
The repo includes an explainer with non-goals, locale data, and tests. If you have production samples or edge cases, please share them — they’re exactly what a future proposal needs.
Try it & tell me what breaks
- Live demo: https://jlorenzetti.github.io/text-wrap-minor-words/
- Repo: https://github.com/jlorenzetti/text-wrap-minor-words
- CodePen: https://codepen.io/jlorenzetti/pen/zxvXewX
Got an edge case (especially in English display, or a new locale)? Open an issue or drop a comment — examples and screenshots are gold.
Top comments (1)
If you try this, I'd love your feedback and edge cases — especially for English display and new locales. Screenshots welcome!
Live demo → jlorenzetti.github.io/text-wrap-mi...