🅭

Font-independent pixel-perfect negative CSS text-indents

The CSS text-indent property is used to offset the first line of text in a text block from the parent element’s inner box (the content area). It behaves like the padding-inline-start property, but only for a paragraph’s first line of text. It’s meant to allow your design to e.g. indent the first line to designate the start of a new paragraph (a more compact alternative to separating paragraphs by empty lines).

The text-indent property has some additional uses with negative values. In this article, I’ll explore how the property can be used to implement hanging punctuation and list item markers. I’ll also discuss how difficult it is to know how many pixels to subtract for the desired effect, and how you should implement it instead. Some familiarity with CSS syntax, layout concepts, and common properties is assumed.

The CSS Text Module includes a hanging-punctuation: first property. It’s meant to let leading paragraph punctuation, such as opening quotation marks in a blockquote, be hung adjacent to the first paragraph but be outside the content area. This effect lets the text itself maintain a rigid line against the left-side gutter.

The hanging-punctuation doesn’t indiscriminately hang the first character or punctuation mark in front of a paragraph’s inner box. Instead, it’s restricted to only apply the effect on a set of language-specific punctuation marks — a behavior that can lead to unpredictable and undesirable results. At the time of writing, and for the last seven years, only Safari has implemented the hanging-punctuation: first property. That is unlikely to change in the slight future.

As we can’t rely on that property, the text-indent property using a negative value can get the job done instead. That raises the question: how much negative indentation do you need? The answer is 1ch (the width of a single 0-character) when using a fixed-width (monospace) font. For proportional fonts, the question is a lot more complicated to answer.

The fundamental problem is that different characters have different widths across different typefaces. Different font rendering software, properties of the display technology, and typefaces influence the rendering pipeline and yield different results through rounding decisions and tolerance accumulation. The larger the type, the bigger the variation.

The problem is compounded when using a font stack consisting of system-provided fonts, as these will differ greatly between devices (especially between Android and anything else). You simply can’t know the exact width of e.g. a double-opening quotation mark to a subpixel-perfect precision.

You can approximate a value based on what you see on your screen and use font-relative CSS length units like em. In CSS, the 1em is equal to the element’s font-size. In traditional typesetting, the unit referred to the width of a typeface’s “M” character at a given point size. It’s probably good enough to create a simple hanging punctuation effect. The result won’t be the same and some end-users will see different levels of drift from your intended design.

You’ll see this problem more clearly in a list of items with numbered markers in front of them (e.g. the HTML ol (organized/numbered list) element. The numbers in proportional fonts have varying widths; e.g. the numbers 4 and 7 are often wider than 2. Numbers with multiple digits are wider still. Despite the varying widths, HTML lists maintain a rigid edge against their left gutter.

Hmm, but wait. How do HTML lists solve this problem? To quote the current working draft of the CSS Lists Module specification, CSS does not specify the precise location of the marker box or its position[.] The draft specification goes into more details, but ultimately clarifies that [the marker position specification] is handwavey nonsense [and] needs a real definition. The behavior of list item markers is achieved through consensus among web rendering engines, and not through the standards efforts of the CSS Working Group.

You can inspect a list item and a list item marker using the built-in web developer tools in a web browser. However, you won’t find any positional or dimension properties affecting the marker in the browser’s default style sheet. The HTML list predates CSS, and 25-years later, it has yet to define how you change the spacing between a list marker and a list item. The spacing between a list item marker and the list items is a single space character (Unicode U+20). You can only manipulate the distance by changing the font size, letter spacing, or font-family properties.

So, let’s recreate list markers using CSS. This should give us something that can be used as hanging punctuation and a custom block box that behaves as a list-item box. Attempting to do this with just negative text indentation doesn’t work as we don’t know the width of the character(s) we’re offsetting. As CSS doesn’t provide any functions for measuring character widths, let’s make the length a known quantity instead.

To achieve a known width for the marker, we create an inline-block element for it. This element can be a ::before pseudo-element and CSS generated content, as used in the below example, or a real element containing the desired character or content. Although tempting, you can’t use a cheeky ::first-letter pseudo-element to implement this. That pseudo-element only supports a limited set of CSS properties. We then give that element a fixed width and right-align its content to move it snugly up to the main content.

Right-aligning the contents of the inline-block element — whether it’s a bullet, punctuation mark, or number — is the real trick to this technique. It negates the need to size the inline-block element precisely to its contents.

For the container, set a left padding and a negative text indentation with a value equal to the width of the inline-block element. The left padding ensures subsequent wrapped lines of text aligns to the same gutter. The negative text-indentation accounts for the inline-block element’s width on the first line

Below you can find all of the above combined into one example stylesheet. The result is a close approximation of how HTML lists work, but it’s also suitable for implementing hanging punctuation. You can use a CSS Counter to implement an ordered/numbered list using the same method. Unlike HTML lists, you get full control of the spacing between the marker and the list item contents. The example uses CSS variables to ensure it uses the same dimensions everywhere instead of relying on magic numbers. “Magic numbers” is a programming term that refers to unexplained, often repeated numbers, in source code.

.list-item-box
{
--left-gutter-width: 2em;

  display: block;
  padding-left: var(--left-gutter-width);
  text-indent: calc(
    0px - var(--left-gutter-width)
  );
}

.list-item-box::before
{
--marker-spacing: 0.25em;

  content: '•';
  display: inline-block;
  margin-right: var(--marker-spacing);
  min-width: calc(
    var(--left-gutter-width) - var(--marker-spacing)
  );
  text-align: right;
}

Alternatively, you can forgo the negative text indentation and left padding on the container box, and rely on a negative right margin on the inline-block element. This method is less reliable, though, as this layout doesn’t account for the space taken up by the inline block element. You generally want to avoid unaccounted for negative values in your layouts.