Skip to content

Chapter VI

Text component

An opinionated API for a text component.

  • Be deliberate about which elements set text styles.
  • Build the habit of choosing semantic elements.
  • Add a screenreaderOnly flag to your text component.
  • Include options for special wrapping and breaking cases.

Using what earlier chapters covered, we can design a text component’s API deliberately, offering only options with a clear use.

Note

The examples below are written in JSX. It makes component design easier to follow than other languages, and it has a few features that help us here.

We can skip a font size option by using scoped values. As the end of the font size chapter explains, where text is placed can set its size. Roomier areas get larger sizes, and denser areas get smaller ones. The text component doesn’t choose its size; its place in the layout does. Fewer choices like this lead to more consistent styling.

This also means line height and letter spacing can be handled automatically, using what we learned in earlier chapters.

Sometimes you need to style text inside other text. With <strong>, <em>, and even <a>, you want the font styles of the surrounding text to pass through, then add extra styles on top. Take this example:

<figure>
<figcaption>
An elephant at sunset by <a href="#">A. Author</a>
</figcaption>
<figure>

Here, we’d expect the figcaption to use Detail text, including the a inside it. Links elsewhere on the page might use Body, or even Heading text. So inline elements should inherit their parent’s text styles by default, and only certain other elements should set them. Be careful about which elements set text styles. If more elements set font properties than you need, you’ll spend more time overriding styles.

Here’s a partial list of semantic elements for each text category:

  • Heading applies to h# elements.
  • Body applies to the body element, or an element marked data-rootwhen you can’t reach the <body>.
  • Detail applies to figcaption, caption, and small elements.

This way, only a few elements set font properties, and the rest can inherit them.

<text.body>
<!-- App data -->
</text.body>
.text {
/** Properties for all text */
&:where(body, [data-root]) {
/** Font properties for Body */
}
&:where(h1, h2, h3, h4, h5, h6) {
/** Font properties for Heading */
}
&:where(figcaption, caption, small) {
/** Font properties for Detail */
}
}

Many component libraries offer an optional tagName or as property. I recommend making the tag name required. Otherwise, people may build layouts that lack semantic meaning, assuming a component called “Heading” will render the right level for them. Instead, I recommend the dot-notation made popular by libraries like Styled Components.

/** Could render something unsemantic without tagName */
<text/>
/** h2 is required to render */
<text.h2/>
Note

TypeScript could also make props required. But dot-notation builds the habit of choosing a semantic element first, before any other option.

The .sr-only class is often used to justify adding a whole utility class library. Instead, let’s look at why we need “screenreader only” styles.

We use .sr-only to hide content visually while still letting assistive technologies, like screen readers, read it. That content is almost always text, so these styles belong with text. Here’s Scott O’Hara’s recommended visually hidden class:

.visually-hidden:not(:focus):not(:active) {
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
position: absolute;
white-space: nowrap;
width: 1px;
}

The :not(:focus):not(:active) selectors show the element again when it’s focused or active. This matters for things like links that skip to the main content or navigation. Instead of a utility class anyone can use anywhere, we can add a screenreaderOnly flag that applies these styles inside the text component:

<text.a href="#main" screenreaderOnly>
Jump to main content
</text.a>

This way, we don’t need to justify utility classes. Instead, components offer standard options for these cases.

Building on the wrapping chapter, we’ll add some flags that control how text wraps and breaks.

  • Create a wrap flag with the following values:
    • (default) if the element has a recommended text-wrap-style, use it. For example, balance for short text and pretty for longer text.
    • true is the same as text-wrap-mode: wrap.
    • false is the same as text-wrap-mode: nowrap.
  • Create a break flag with the following values:
    • (default) if the element has a recommended overflow-wrap, use it. Otherwise, overflow-wrap: anywhere, so long URLs break neatly inside text, even in boxes that size to their content. The wrapping chapter explains why this beats break-word.
    • true is the same as word-break: break-all.
    • false is the same as word-break: keep-all.

The wrap default depends on the element. Here’s the text-wrap-style I’d recommend for each kind of semantic element:

text-wrap-style Semantic elements Reason
balance dt, legend, label, th They act as headings for content and are usually a few lines or less.
stable textarea, [contenteditable] User input makes text shift in the container.
pretty Everything else Usually several lines of prose.

The break default depends on the element too. Here’s the overflow-wrap I’d recommend:

overflow-wrap Semantic elements Reason
normal dt, label, legend They label something else and are usually a word or two.
normal th, td Table columns size to their content, so anywhere lets the browser squeeze cells until words break between letters. Keeping words whole lets the table grow wider and scroll instead.
anywhere Everything else Long strings, like URLs, break inside boxes that size to their content instead of spilling out.

You could add a property to turn these styles on, but then they’d be used inconsistently. Tying them to the element is one more reason to choose the right semantic element. On the other hand, developers might be surprised that the element they choose changes how text wraps. Be sure to document how each element affects the presentation.

Note

Why have an explicit wrap=true flag? Some elements don’t wrap by default. The <pre> element, for example, uses text-wrap-mode: nowrap. To make its text wrap while keeping its whitespace, change it to text-wrap-mode: wrap. The shorthand for this is white-space: pre-wrap.

The code below uses the proxy pattern from system.damato.design, which builds the component from any HTML tag. The line break option is renamed breakFlag because break is a reserved word in JavaScript. Rename it however you like:

export const text = proxy('text', (TagName) => {
return ({
break: breakFlag,
wrap,
screenreaderOnly = false,
...rest
}) => {
return (
<TagName
{ ...rest }
data-break={breakFlag}
data-wrap={wrap}
data-screenreader-only={screenreaderOnly || undefined}/>
)
}
})

The inputs to the formulas, such as --base, --ratio, and the font metrics, aren’t set here. They’re scoped values set outside the component, so each region of the page can change them. The formulas are written once on .text, and each category only picks its size. Letter spacing runs from the Detail size to the Body size, and Headings get one value of their own, as the letter spacing chapter explains. Line height only needs the Detail size. Neither depends on a Heading’s fluid size, so Body and Detail text stay static as the window is resized.

Note

Every Heading here sits on one step, --heading-step, which a region sets for its density. That suits an app. If you’re setting prose, with a title, sections, and subsections in one region, each level needs a step and a safe factor of its own. Set those from outside too, as described in Heading levels, and give the Heading face a tracking line instead of one value.

.text {
overflow-wrap: anywhere;
text-wrap-style: pretty;
--fs-detail: calc(var(--base) * pow(var(--ratio), var(--detail-step)));
--fs-body: calc(var(--base) * pow(var(--ratio), var(--body-step)));
--fs-heading: calc(var(--base) * pow(var(--ratio), var(--heading-step)));
--fs-heading-max: calc(var(--fs-heading) * var(--fs-heading-safe-factor));
--fs-heading-fluid: calc(
progress(100vw, var(--w-min), var(--w-max))
* (var(--fs-heading-max) - var(--fs-heading))
+ var(--fs-heading)
);
--glyph: calc((var(--cap-height) - var(--descender)) / var(--units-per-em));
--leading: calc((var(--ascender) - var(--cap-height) + var(--line-gap)) / var(--units-per-em));
/* Normal at the Detail size, then the space between lines stays the same.
The ratio multiplied through by 1em: a length, so nothing divides by 1em
and a grid can round it. */
--line-height: calc(var(--glyph) * 1em + var(--leading) * var(--fs-detail));
/* The Body face's line runs from the Detail size to the Body size. */
--letter-spacing: calc(
progress(1em, var(--fs-detail), var(--fs-body))
* (var(--ls-min) - var(--ls-max))
+ var(--ls-max)
);
/* Only the categories set font properties; everything else inherits. */
&:where(body, [data-root]) {
font-family: var(--ff-body);
font-size: var(--fs-body);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing);
}
&:where(h1, h2, h3, h4, h5, h6) {
font-family: var(--ff-heading);
font-size: var(--fs-heading-fluid);
line-height: var(--line-height);
letter-spacing: var(--ls-heading);
}
&:where(figcaption, caption, small) {
font-family: var(--ff-detail);
font-size: var(--fs-detail);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing);
}
&:where(h1, h2, h3, h4, h5, h6, dt, legend, label, th) {
text-wrap-style: balance;
}
/* Words stay whole in labels, which are a word or two, and in table cells,
which a column would otherwise squeeze until words break between letters. */
&:where(dt, label, legend, th, td) {
overflow-wrap: normal;
}
&:where(textarea, [contenteditable]) {
text-wrap-style: stable;
}
&:where([data-wrap="true"]) {
text-wrap-mode: wrap;
}
&:where([data-wrap="false"]) {
text-wrap-mode: nowrap;
}
&:where([data-break="true"]) {
word-break: break-all;
}
&:where([data-break="false"]) {
word-break: keep-all;
}
&:where([data-screenreader-only]:not(:focus, :active)) {
overflow: hidden;
clip-path: inset(50%);
height: 1px;
position: absolute;
white-space: nowrap;
width: 1px;
}
}

You’ll notice each category sets line-height and letter-spacing again, even though a Heading inside body would inherit them. That’s because both are worked out on the element that sets them. The 1em in the formulas is the font size of body, so a Heading would inherit Body’s line height and tracking, worked out for Body’s size. Setting them on each category reruns the formulas at that category’s own font size. Inline elements don’t need this. They share their parent’s font size, so the inherited values are already correct.

To support a <text.details> element, you can add the text disclosure CSS here, with an option to set --max-lines.