Balanced Heading
A heading that breaks its lines to even lengths instead of stranding one word on the last line — pure CSS text-wrap with a copy-safe fallback for engines that lack it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/balanced-heading.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "BalancedHeading" component (no runtime
dependencies beyond the shared cn() class-merge helper; no hooks, no effects,
no measurement, no "use client").
Contract
- Named export `BalancedHeading`, forwardRef<HTMLElement>, props extend native
HTML attributes:
- `as?: "h1".."h6" | "p" | "div" | "span"` — default `"h2"`. The heading
level is the caller's decision; the component never picks one.
- `wrap?: "balance" | "pretty" | "normal"` — default `"balance"`. Maps to the
Tailwind classes `text-balance` / `text-pretty` / `text-wrap`.
- `guardLastPair?: boolean` — default `true`. Emits the fallback described
below. Skipped entirely when `wrap="normal"`.
- Root carries `data-slot="balanced-heading"`, the fallback span carries
`data-slot="last-pair"`, so consumers can target either from outside.
Behavior
- The whole feature is one CSS declaration, which is why there is no client
boundary: the first server-rendered frame is already the final layout. Do not
reach for JavaScript measurement here — every JS "balance" polyfill costs a
layout pass per resize and still cannot beat the browser's own line breaker.
- `balance` evens out the lines. Measured in Edge 150 (Chromium) with an
explicit sans stack: a 520px box at 32px went from line widths
[503.91, 168.88] to [344.56, 328.22] (standard deviation 167.52 -> 8.17) at
two lines, and from [486.36, 479.98, 61.39] to [370.52, 436.52, 220.70] at
three. Across 36 heading/width pairs the mean line-width standard deviation
fell from 87.37 to 28.27 and never rose in a single case. Balance is also
optimal, not just better: enumerating every possible hard-break split of the
same words, balance matched the minimum-spread split in 22 of 22 two-line
cases and in all three multi-line cases checked (3 and 4 lines). Where it
leaves the greedy break alone, that break was already the optimum.
- `balance` has a hard ceiling of SIX LINES. From seven lines on it is a
complete no-op: same text, the line breaks are byte-identical to plain
wrapping (verified at 520px/32px, 360px/24px and 760px/40px — it reshaped the
text at 2,3,4,5,6 lines and did nothing at 7..13). This is a deliberate
performance guard in the engine, not a bug, and it is the single most
important thing to know when choosing between the two values: anything that
can grow past six lines should ask for `pretty`.
- `pretty` is the cheap, unlimited one, and it is NOT a weaker `balance`: it
only intervenes when the natural last line would be a single word, and only
when that word is narrow. Measured at 520px: a last line of 169.98px (32.7%
of the box) was rescued, 177.34px (34.1%) was not — the threshold sits around
a third of the line. When the last line already has two or more words it
changes nothing at all. It has no line ceiling: it still acted at 7, 11 and
12 lines where balance was dead.
- Rule of thumb to document for the consumer: `balance` for short display
headings (one to three lines), `pretty` for body copy, long titles and
anything user-generated whose length you do not control.
- Fallback for engines without `text-wrap` (`guardLastPair`): wrap the last two
words of the last text child in a span that is `display: inline-block;
max-width: 100%`, and revert it to `display: inline` inside
`@supports (text-wrap: balance)` (or `(text-wrap: pretty)` — gate on the value
the caller actually asked for, since engines shipped `balance` first). An
inline-block cannot be split across lines, so the last two words travel
together and no word is stranded; `max-width: 100%` is the part that makes it
safe, because a pair wider than the container wraps INSIDE the box instead of
running past it.
- Do not use `white-space: nowrap` for that span, which is the usual advice. It
produces exactly the same rescue when the pair fits and blows the layout apart
when it does not: measured over 19 headings x 7 container widths (120px-760px)
it pushed text outside the container in 31 of 133 combinations, by up to
158px, while the inline-block version added zero overflow in all 133.
- Do not use ` ` or U+2060 WORD JOINER either. ` ` does fix the break,
but it substitutes U+00A0 for U+0020, so `textContent` and every copy of the
heading come back wrong. A word joiner around the space does not even change
the line break (measured: identical line widths, last line still one word) and
still lands U+2060 in the clipboard. `<wbr>` is the opposite tool — it ADDS a
break opportunity — and measurably does nothing here.
- The guard therefore only ever inserts an element boundary, never a character.
Verified with a real Cmd+C on a secure origin: the clipboard string is
identical to the source string for ASCII, punctuation, digits and CJK, with
and without the guard.
- Splitting the text node does perturb text shaping by hundredths of a pixel.
Measured over 266 combinations: the line BREAKS were identical in 266 of 266,
and the largest line-width difference was 0.02px. Assert on break positions,
not on pixel-exact widths, if you test this.
- Boundaries: with no children, one word, or two words, nothing is emitted (with
two words the "pair" would be the whole string, and binding a string that
already fails to fit changes nothing). When the last child is an element
rather than text (`Ship <em>faster</em>`), the guard is skipped rather than
guessing where the last word lives inside someone else's markup. Runs of
spaces and newlines survive verbatim.
Rendering & styling
- Semantic tokens only; the component ships no colour, size or weight at all —
the caller's `className` supplies the typography, and `cn()` merges it. That
merge is load-bearing: `text-balance` and `text-nowrap` are the same
tailwind-merge group, so a caller passing `text-nowrap` correctly wins, while
`text-muted-foreground` / `text-4xl` / `text-center` all coexist.
- No animation, so there is nothing for prefers-reduced-motion to switch off.
- Accessibility: the element is whatever `as` says it is, and the guard span is
a plain inline with no role and no aria — screen readers read the same string
either way, because the string is the same string.
Customization levers
- `wrap` — `"balance"` for hero and card titles, `"pretty"` for paragraphs and
any text of unknown length, `"normal"` to opt a specific instance out (it also
overrides a `text-pretty` inherited from a parent).
- `as` — set the real heading level for the document outline and style the size
through `className`; the two are deliberately independent.
- `guardLastPair={false}` — drop the extra span when you only care about modern
engines, when the heading ends in a very long compound word you would rather
let wrap on its own, or when your heading text is a single unbroken CJK run
(no spaces means no pair to bind, and the guard is skipped anyway).
- Add a `max-w-[20ch]` / `max-w-2xl` on the caller's side to cap the measure —
balancing a 1400px-wide single line does nothing, since there is only one line
to balance. Line length is still the caller's decision.
- To balance a heading longer than six lines, split it into two elements (a
headline plus a `wrap="pretty"` standfirst) rather than fighting the engine
ceiling; that is also better typography.
- To bind three words instead of two, widen the trailing regex from
`(\S+\s+\S+)` to `(\S+\s+\S+\s+\S+)`. Nothing else changes — but the wider the
bound run, the more often it will be the full-line case where the guard is a
no-op.
- To style the bound pair (a debugging aid, or a deliberate `whitespace-nowrap`
brand treatment) target `[data-slot=last-pair]` from the caller's className.Concepts
- Balanced vs greedy line breaking — the default breaker fills each line as full as it can and lets the remainder fall onto the last one, which is how a headline ends up as two full lines and one lonely word.
text-wrap: balancesearches for the split that evens the lines instead; enumerating every possible split of the same words showed it landing on the minimum-spread one in all 25 cases measured. - The six-line ceiling — balancing is quadratic-ish work, so Chromium simply stops doing it once a block would take more than six lines. Past that point
text-wrap: balanceis not "less effective", it is inert: the line breaks come back byte-identical to plain wrapping. It is the reason this component exposesprettyas a first-class sibling rather than a lesser mode. prettyis widow insurance, not balancing — it fires only when the last line would be a single word narrower than roughly a third of the line (measured threshold between 32.7% and 34.1% of a 520px box), leaves every other heading untouched, and keeps working at any length. Cheap enough for body copy, whichbalancenever is.- Progressive enhancement through
@supports— the fallback markup ships to everyone, but a browser that implementstext-wrapreverts the guard span to a plain inline, so it never competes with the engine's own line breaker. Across 266 heading/width/mode combinations the guard changed zero line breaks in a supporting browser. - Atomic inline as a safe binder — the guard is
inline-block, notwhite-space: nowrap. Both stop the last two words from splitting; only the inline-block also honoursmax-width: 100%, so an oversized pair wraps inside its own box. The nowrap version pushed text outside the container in 31 of 133 measured combinations, by up to 158px. - Copy fidelity — the guard inserts an element boundary, never a character, so a selection and a real Cmd+C return the original string byte for byte. The folk remedies do not:
swaps U+0020 for U+00A0 in your clipboard, and U+2060 WORD JOINER pollutes the string while measurably not changing the line break at all.
Fit Text
One line of text scaled until it exactly fills the width it was given — headline, KPI number or cover title, sized from a real browser measurement instead of an estimate.
Pull Quote
An editorial pull quote for body copy — centered, floated left or right with text wrapping around it, or bled past the measure.