React Wrap Balancer wraps text in a <span> and adjusts its max-width so lines fill evenly — no orphan single words dangling on the last line of a heading.
Why I starred it
The CSS text-wrap: balance property is the right answer here, but browser support was thin in early 2023. Adobe and the NYT had both shipped their own JS implementations (balance-text and text-balancer respectively), but neither was a drop-in React component with SSR support baked in.
What made me pause wasn't the feature — it was the implementation decision. Instead of measuring text nodes or approximating character counts, Shu Ding went with a binary search on container width. That's a choice worth understanding.
How it works
Open src/index.tsx and find the relayout function. This is the core:
// Reset wrapper width
wrapper.style.maxWidth = ''
const width = container.clientWidth
const height = container.clientHeight
let lower: number = width / 2 - 0.25
let upper: number = width + 0.5
let middle: number
update(lower)
lower = Math.max(wrapper.scrollWidth, lower)
while (lower + 1 < upper) {
middle = Math.round((lower + upper) / 2)
update(middle)
if (container.clientHeight === height) {
upper = middle
} else {
lower = middle
}
}
update(upper * ratio + width * (1 - ratio))
The trick: set the wrapper's max-width to something narrow, then binary-search upward until the container height stops increasing — that's the minimum width that fits without adding a new line. Then optionally scale back toward full width using the ratio prop (default 1, meaning fully balanced).
This is a forced reflow per iteration, which sounds expensive. In practice it's bounded — binary search on a container width of 1000px converges in about 10 iterations. The ratio prop lets you dial back the aggressiveness if you only want to avoid the worst cases.
The SSR story is clever. The component injects an inline <script> tag that embeds the serialized relayout function and calls it immediately with the element's data-br id. The function is serialized via .toString() — you can see this in how RELAYOUT_STR is defined — so the balance calculation runs during HTML parsing, before hydration. That's why it doesn't flash on load.
When multiple <Balancer> components exist, each one would re-embed the full function string. The <Provider> wrapper deduplicates this: it injects the function definition once at the top, and each child's script tag becomes a short call instead of a full function body. The contextValue.hasProvider flag controls this — createScriptElement(injected, nonce, suffix) skips the function definition when injected is true.
The preferNative prop (default true) checks CSS.supports("text-wrap", "balance") once and stores the result in self.__wrap_n. If the browser supports it natively, the component adds text-wrap: balance via inline style and skips the JS binary search entirely. As of 2024, this means modern Chrome, Edge, and Safari all skip the JS path completely.
src/utils.tsx has a polyfill for React.useId — the hook that generates stable element IDs across server and client. If useId exists on the React import, it uses that; otherwise it falls back to a monotonic counter with a rwb- prefix and a useLayoutEffect to trigger a re-render post-hydration. Solid engineering for a library that claims React 16+ support.
Using it
npm i react-wrap-balancer
import Balancer from 'react-wrap-balancer'
import { Provider } from 'react-wrap-balancer'
// Wrap your app once to share the relayout fn across all instances
function App() {
return (
<Provider>
<h1><Balancer>This title will never leave a single word on its own line</Balancer></h1>
</Provider>
)
}
The ratio prop is useful when you want subtle improvement rather than fully compact wrapping:
// 0 = no balance (browser default)
// 1 = fully balanced (default)
<Balancer ratio={0.5}>Slightly balanced heading</Balancer>
There's a dev-mode warning if you accidentally wrap a block element inside <Balancer>:
<Balancer> should not wrap <p> inside. Instead, it should directly wrap text or inline nodes.
Good DX — this is exactly the mistake I'd make.
Rough edges
No unit tests. The test/ folder contains only a benchmark — two HTML files and a gen.js to produce test cases. The benchmark measures SSR render time but there's no assertion suite.
The binary search triggers multiple synchronous reflows. On a page with 50 headings and no <Provider>, that's 50 × ~10 forced reflows during initial paint. The <Provider> reduces HTML weight but doesn't batch the reflows. For content-heavy pages, you'd want to profile this.
preferNative defaults to true, which means on modern browsers the component is effectively just adding text-wrap: balance via inline style. The binary search is the interesting part, but it's increasingly unused.
The repo hasn't had a substantial commit in about a year. It's in a stable state — the native CSS approach has largely superseded the JS path — but don't expect active development.
Bottom line
Use this if you're on React and want balanced headings without worrying about browser support. The native CSS fallback means it's zero-overhead on modern browsers, and the SSR script injection means no layout shift anywhere.
