Orphan Control in CSS and JavaScript: Prevent Single Words on the Last Line

deep-diveMarch 29, 2026· 9 min read

There's a moment when you're reading an article and something feels slightly off. You scan back. There it is: a single word sitting alone on the last line of a paragraph. Just "the" or "a" or — if you're unlucky — "I" hanging in empty space.

Typographers call this an orphan. It's a small thing. But it creates visual noise, disrupts the reading rhythm, and signals that nobody cared enough to fix it. On a blog or editorial site, it's the typographic equivalent of a typo.

Fixing it in print has been standard practice for decades. Fixing it on screen is surprisingly hard.

What Is an Orphan?

Technically, "orphan" and "widow" have distinct meanings in print typography:

  • Orphan: the first line of a paragraph stranded at the bottom of a page or column
  • Widow: the last line of a paragraph stranded at the top of the next page or column

On the web, both terms get used loosely. What we're solving here is the most visible variant: a single word on the last line of a text block — whether that's a heading, paragraph, or pull quote.

A heading like this is fine:

Building Better Web Experiences

But this creates an orphan:

Building Better Web Experiences in
JavaScript

The word "JavaScript" dangling alone on the second line looks wrong. The visual weight is unbalanced. The reader's eye gets pulled to the whitespace rather than the text.

For headings, this is especially noticeable because there's more surrounding whitespace and users read them more carefully. For body copy, single-word last lines create a ragged right edge that disrupts the paragraph's rectangular shape.

CSS orphans — Not What You Think

CSS has an orphans property:

p {
  orphans: 2; /* minimum 2 lines at bottom of a page/column */
}

But this is only for paged media — print stylesheets, @page rules, multi-column layouts where content flows across pages. It tells the browser: don't leave fewer than N lines of this paragraph at the bottom of a printed page.

It does absolutely nothing for words on screen. It's about lines, not words, and only applies to page/column breaks.

So: orphans: 2 is useless for our problem. You can set it all day and the single-word last line stays exactly where it is on screen.

The Manual Non-Breaking Space Hack

The old-school typographic hack: replace the space between the last two words with a non-breaking space ( ):

<!-- This keeps "JavaScript" with "in" -->
<h2>Building Better Web Experiences in&nbsp;JavaScript</h2>

This works. The browser won't break the line between "in" and "JavaScript", so both words stay together.

The problem: it's manual. Every heading, every paragraph, every piece of content needs to be hand-checked. It breaks when content is dynamic or user-generated. It breaks when the container width changes. It breaks when font size changes for accessibility reasons.

It's fine as a one-off fix. It doesn't scale.

The text-wrap: balance Approach

CSS added text-wrap: balance — and it's genuinely great for headings:

h1, h2, h3 {
  text-wrap: balance;
}

text-wrap: balance tells the browser to distribute words across lines as evenly as possible, rather than greedily filling each line left-to-right. For short headings, this naturally eliminates single-word last lines in most cases:

Without balance:                  With balance:
Building Better Web               Building Better
Experiences in                    Web Experiences
JavaScript                        in JavaScript

It genuinely works — but with important limitations:

Browser support: Chrome 114+, Firefox 121+, Safari 17.4+. Not available in older browsers, though the failure mode (no balance) is graceful.

Length limit: The spec allows implementations to limit balancing to a small number of lines (the CSS spec suggests 6 as a reasonable cap). Chrome limits it to lines where the calculated difference is within a factor — very long paragraphs may not balance.

Performance: For long text, the browser has to run a layout search to find the balance point. This is fast for short headings but can be slower for long body copy. Don't apply it to every <p> on the page.

Word control: text-wrap: balance balances line lengths, not necessarily word distribution. A balanced last line might still have one word if the text naturally breaks that way.

So: use text-wrap: balance on headings. It handles the common case. But for precise control — especially for programmatic content or situations where balance doesn't eliminate the orphan — you need a code-based approach.

Detecting Orphans with Pretext

Pretext has a walkLineRanges() API that iterates over the lines of a text block without building the actual line strings. It's fast, canvas-based, and requires no DOM:

import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'

function getLastLineInfo(text, font, maxWidth) {
  const prepared = prepareWithSegments(text, font)
  let lineCount = 0
  let lastLineStart = 0
  let lastLineEnd = 0

  walkLineRanges(prepared, maxWidth, (line) => {
    lineCount++
    lastLineStart = line.start
    lastLineEnd = line.end
  })

  const lastLineText = text.slice(lastLineStart, lastLineEnd).trim()
  const wordCount = lastLineText.split(/\s+/).filter(Boolean).length

  return { lineCount, lastLineWords: wordCount, lastLineText }
}

const result = getLastLineInfo(
  'Building Better Web Experiences in JavaScript',
  'bold 24px Georgia, serif',
  320
)
// { lineCount: 2, lastLineWords: 1, lastLineText: 'JavaScript' }

This tells us there's an orphan: the last line has 1 word.

Fixing It: Nudging max-width

Once we detect an orphan, the fix is to narrow the container slightly until the last line has at least 2 words. Narrowing the container causes a reflow: text that was on the last line gets pushed up to join the previous line, which redistributes the words.

We use binary search to find the narrowest width that results in ≥2 words on the last line:

import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'

function findBalancedWidth(text, font, originalWidth) {
  const prepared = prepareWithSegments(text, font)

  function lastLineWordCount(width) {
    let lastLineText = ''
    walkLineRanges(prepared, width, (line) => {
      lastLineText = text.slice(line.start, line.end).trim()
    })
    return lastLineText.split(/\s+/).filter(Boolean).length
  }

  // Check if there's actually an orphan
  if (lastLineWordCount(originalWidth) >= 2) {
    return originalWidth // no fix needed
  }

  // Binary search for narrowest width that gives >=2 words on last line
  let lo = originalWidth * 0.7 // don't go narrower than 70%
  let hi = originalWidth

  for (let i = 0; i < 12; i++) {
    const mid = Math.round((lo + hi) / 2)
    if (lastLineWordCount(mid) >= 2) {
      hi = mid
    } else {
      lo = mid + 1
    }
  }

  return hi
}

const adjustedWidth = findBalancedWidth(
  'Building Better Web Experiences in JavaScript',
  'bold 24px Georgia, serif',
  320
)
// Returns something like 295 — slightly narrower, causes "JavaScript" to join "in" on line 2

The binary search converges in 12 iterations (log₂(320 * 0.3) ≈ 6.2 — 12 is safe). Each iteration is a pure walkLineRanges call: no DOM, fast, synchronous.

The React Hook

Packaging this into a React hook makes it easy to apply to any text element:

import { useState, useEffect } from 'react'
import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'

interface UseBalancedTextResult {
  adjustedMaxWidth: number
  isOrphan: boolean
}

function useBalancedText(
  text: string,
  font: string,
  containerWidth: number
): UseBalancedTextResult {
  const [result, setResult] = useState<UseBalancedTextResult>({
    adjustedMaxWidth: containerWidth,
    isOrphan: false,
  })

  useEffect(() => {
    let cancelled = false

    const run = async () => {
      await document.fonts.ready

      const { prepareWithSegments, walkLineRanges } = await import('@chenglou/pretext')
      const prepared = prepareWithSegments(text, font)

      function lastLineWordCount(width: number): number {
        let lastLineText = ''
        walkLineRanges(prepared, width, (line) => {
          lastLineText = text.slice(line.start, line.end).trim()
        })
        return lastLineText.split(/\s+/).filter(Boolean).length
      }

      const originalWordCount = lastLineWordCount(containerWidth)
      if (originalWordCount >= 2) {
        if (!cancelled) setResult({ adjustedMaxWidth: containerWidth, isOrphan: false })
        return
      }

      let lo = containerWidth * 0.7
      let hi = containerWidth
      for (let i = 0; i < 12; i++) {
        const mid = Math.round((lo + hi) / 2)
        if (lastLineWordCount(mid) >= 2) hi = mid
        else lo = mid + 1
      }

      if (!cancelled) setResult({ adjustedMaxWidth: hi, isOrphan: true })
    }

    run()
    return () => { cancelled = true }
  }, [text, font, containerWidth])

  return result
}

Usage is clean:

function Heading({ children, containerWidth }) {
  const { adjustedMaxWidth } = useBalancedText(
    children,
    'bold 24px Georgia, serif',
    containerWidth
  )

  return (
    <h2 style={{ maxWidth: adjustedMaxWidth }}>
      {children}
    </h2>
  )
}

The heading gets a max-width computed to eliminate the orphan. The visual difference is subtle but consistent across all viewport sizes.

Important: walkLineRanges and line.text

One subtlety: in some versions of the pretext API, walkLineRanges passes a line object with start and end character indices (into the original text), not the line text itself. The line.text property may or may not be present depending on which API you use.

Use prepareWithSegments() + walkLineRanges() for this use case (as opposed to the simpler prepare() + layout()), and slice the text yourself:

import { prepareWithSegments, walkLineRanges } from '@chenglou/pretext'

const prepared = prepareWithSegments(myText, font)
walkLineRanges(prepared, containerWidth, (line) => {
  const lineText = myText.slice(line.start, line.end)
  // now you have the actual text of each line
})

Interactive Demo

Toggle orphan control on and off to see the adjustment in action. The nudged width is shown in blue when a fix has been applied:

⚡ Orphan Control Demo
Computing…

Notice how the "Building Better Web Experiences in" example adjusts: with orphan control on, the max-width is narrowed slightly, causing "JavaScript" to stay with "in" on the previous line.

When to Use Which Approach

ApproachUse when
text-wrap: balanceShort headings (≤6 lines), modern browser target, simple cases
Manual &nbsp;One-off editorial fixes, static content you fully control
Pretext walkLineRangesDynamic content, precise control, programmatic text rendering, older browser support
CSS orphansPrint stylesheets only — useless for screen

For most editorial sites, the right answer is: text-wrap: balance on headings as the baseline, pretext-based detection for anything that needs guaranteed control or handles dynamic content.

The two approaches don't conflict. You can use text-wrap: balance for its fast, CSS-native handling of the common case, and fall back to pretext for edge cases or environments where balance doesn't eliminate the orphan.

Performance Notes

The walkLineRanges binary search does ~24 layout calls (12 iterations × 2 calls each). Each call is pure arithmetic over pre-computed canvas measurements — prepare() is the expensive step, and you only call it once per text string.

For a page with 50 headings, this is comfortably fast. For 5000 body paragraphs, you'd want to batch and prioritize visible content first, running the checks during idle time:

requestIdleCallback(() => {
  bodyElements.forEach(el => {
    const { adjustedMaxWidth } = computeBalancedWidth(el.textContent, el.computedFont, el.offsetWidth)
    el.style.maxWidth = `${adjustedMaxWidth}px`
  })
})

Takeaways

  • CSS orphans only applies to printed pages — it does nothing for single words on the last line of a screen element
  • text-wrap: balance is the right CSS-native approach for headings: use it, but know its limits (short text, modern browsers)
  • The manual &nbsp; trick works but doesn't scale to dynamic content
  • Pretext's walkLineRanges() lets you count words on each line without DOM access — detect orphans in pure JavaScript
  • Fix orphans by binary-searching for the narrowest max-width that leaves ≥2 words on the last line
  • The useBalancedText() hook makes this easy to apply in React — runs once after fonts load, returns an adjusted max-width
  • For production use: text-wrap: balance for common cases, pretext for precise control and dynamic content