Detecting Widows in React

August 8, 2019

widows

I’ve been working with fixed-width containers in React recently, so I’ve been able to focus on typesetting. One problem I’ve had is noticing widows in paragraph text as I change copy and styling. (widow is apparently an overloaded term in typesetting – here, I mean a single word that overflows into a new line, not a single line overflowing into a new page.) I ended up building a React component that automates widow detection - let’s dig in!

Getting a container reference in React

First, we’ll build a component that wraps its child in a container. We’ll use a reference to this container to see if the child element has a widow. Our component will report widows with the data-has-widow attribute.

import React from 'react';

class ComponentReportingWidows extends React.PureComponent {
  constructor(props) {
    super(props);
    // prevent undefined errors in render() when state is null
    this.state = {};
  }

  function getLineCount(el) {
    return 0; // TODO
  }

  hasWidows() {
    return false; // TODO
  }

  componentDidMount() {
    this.setState({
      hasWidow: this.hasWidows()
    });
  }

  render() {
    return (
      <div data-has-widow={this.state.hasWidow}
           ref={c => this.container = c}>
        {React.Children.only(this.props.children)}
      </div>
    )
  }
}

Calculating number of lines in an element

Once we have a reference to the container, we need to figure out how many lines of text it contains. We can divide its height by the container’s line height:

function getLineCount(el) {
  const height = el.clientHeight;
  // use computedStyle because line-height could be unset
  const lineHeight = window.getComputedStyle(el)
                           .getPropertyValue("line-height");

  return Math.floor(height / parseFloat(lineHeight));
}

Detecting widows

Finally, we need a way to detect a widowed element. To do this, we’ll create a hidden div with no max-width and outside the normal document flow: (Note - I really wouldn’t do this in production, especially with dynamic elements, since it duplicates the child element)

render() {
  const hiddenContainerStyles = {
    "position": "absolute",
    "visibility": "hidden",
    "max-width": "unset"
  };

  return (
    <div data-has-widow={this.state.hasWidow}
         ref={c => this.container = c}>
      <div style={hiddenContainerStyles}
           ref={hc => this.hiddenContainer = hc}>
        {React.Children.only(this.props.children)}
      </div>
      {React.Children.only(this.props.children)}
    </div>
  );
}

We need this hidden container to get the unconstrained width of the child. With it, we can calculate if a container has a widow. We consider a container widowed if the width of its last line is less than 10% of the total container width. We can figure that out with some clever arithmetic:

const DEFAULT_WIDOW_THRESHOLD = 0.1;

hasWidows() {
  const containerWidth = this.container.clientWidth;
  const containerLines = getLineCount(this.container);

  const hiddenContainerWidth = this.hiddenContainer.clientWidth;
  const trailingLineWidth =
    hiddenContainerWidth - (containerWidth * (containerLines - 1));

  const widowThreshold =
    DEFAULT_WIDOW_THRESHOLD * containerWidth;

  return hiddenContainerWidth > containerWidth &&
         trailingLineWidth - containerWidth < widowThreshold;
}

And that’s it! Putting it all together:

import React from 'react';

const DEFAULT_WIDOW_THRESHOLD = 0.1;

class ComponentReportingWidows extends React.PureComponent {
  constructor(props) {
    super(props);
    // prevent undefined errors in render() when state is null
    this.state = {};
  }

  function getLineCount(el) {
    const height = el.clientHeight;
    // use computedStyle because line-height could be unset
    const lineHeight = window.getComputedStyle(el)
                            .getPropertyValue("line-height");

    return Math.floor(height / parseFloat(lineHeight));
  }

  hasWidows() {
    const containerWidth = this.container.clientWidth;
    const containerLines = getLineCount(this.container);

    const hiddenContainerWidth = this.hiddenContainer.clientWidth;
    const trailingLineWidth =
      hiddenContainerWidth - (containerWidth * (containerLines - 1));

    const widowThreshold =
      DEFAULT_WIDOW_THRESHOLD * containerWidth;

    return hiddenContainerWidth > containerWidth &&
          trailingLineWidth - containerWidth < widowThreshold;
  }

  componentDidMount() {
    this.setState({
      hasWidow: this.hasWidows()
    });
  }

  render() {
    const hiddenContainerStyles = {
      "position": "absolute",
      "visibility": "hidden",
      "max-width": "unset"
    };

    return (
      <div data-has-widow={this.state.hasWidow}
          ref={c => this.container = c}>
        <div style={hiddenContainerStyles}
            ref={hc => this.hiddenContainer = hc}>
          {React.Children.only(this.props.children)}
        </div>
        {React.Children.only(this.props.children)}
      </div>
    );
  }
}

I came across this use case when rewriting my résumé in React – where my constraints were very different from day-to-day web development. Instead of making dynamic content look acceptable on viewports of all shapes and sizes, I was focused on readability for a single size - a single sheet of US Letter.