Text

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.

Preview in your theme

Loading preview…

import * as React from "react"
import { cn } from "@/lib/utils"

export type BalancedHeadingTag = "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "div" | "span"

/**
 * `balance` evens out every line — right for short headings.
 * `pretty` only pulls a word down when the last line would be a lone word —
 * cheaper, no line limit, right for body copy and long titles.
 * `normal` opts out (and overrides an inherited `text-wrap` value).
 */
export type BalancedHeadingWrap = "balance" | "pretty" | "normal"

const WRAP_CLASS: Record<BalancedHeadingWrap, string> = {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/balanced-heading.json

Prompt

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 `&nbsp;` or U+2060 WORD JOINER either. `&nbsp;` 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: balance searches 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: balance is not "less effective", it is inert: the line breaks come back byte-identical to plain wrapping. It is the reason this component exposes pretty as a first-class sibling rather than a lesser mode.
  • pretty is 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, which balance never is.
  • Progressive enhancement through @supports — the fallback markup ships to everyone, but a browser that implements text-wrap reverts 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, not white-space: nowrap. Both stop the last two words from splitting; only the inline-block also honours max-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: &nbsp; 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.

On This Page