Profile Picture

Detecting Widows in React

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.

Enjoyed this post? Follow me on Twitter for more content like this. Or, subscribe to my email newsletter to get new articles delivered straight to your inbox!
Scroll to top