Front-end education for the real world. Since 2018.





Riffing on the latest CSS fit text approach

Andy Bell

Topic: CSS

As promised in our link to Roman Komarov’s ‌Fit-to-Width Text: A New Technique article, I have implemented it and tweaked a couple of bits for our context.

The contextpermalink

My upcoming CSS course is in full production mode at the moment and a big part of the course is that we’re building a front-end for a fictional barista academy in London.

A clip of the academy website home page with a bold yellow and black colour scheme. The academy’s name, “BLOOM”,  is in large yellow letters on a black background, with the subtitle “Barista Academy” and “Established 2019” underneath. Below this, there is a photograph of a smiling barista wearing a yellow beanie and glasses, operating an espresso machine in shot too.

There’s all sorts of little design complexities which we learn to work through via planning, communication and feedback loops throughout the course to arrive at a good compromise that is simpler to build, maintain and most importantly, provide a better user experience.

There’s a couple of bits that remain rather complex because I know y’all wanna learn the good stuff. One of those parts is a section that features container filling text.

A section of the site with a yellow background. The headline reads “Coffee & community.” and is followed by three small photos showing baristas in action and interacting in urban settings. A caption reads “The two go hand in hand” and another, “Our London HQ is home to a community of passionate coffee lovers and baristas.”

Does it need container filling text? We could for sure work around that and when you get a copy of the course, you’ll learn this design iteration is the result of several rounds of working through feedback in collaboration with a designer. It’s a compromise, which are very important to get to!

The solution I previously hadpermalink

I originally opted for a progressively enhanced approach, setting a good minimal viable experience in CSS, then using a web component to set a --container-fill-text-size CSS custom property.

Code language
css

.container-fill-text {
  white-space: nowrap;
  font-size: var(--container-fill-text-size, max(15cqi, var(--size-step-10)));
  line-height: var(--leading-flat);
  font-weight: var(--font-black);
  margin: 0;
}

.container-fill-text > * {
  display: block;
}

/* Even if JavaScript isn't available, the web component will be a useful container */
container-fill-text {
  display: block;
  container-type: inline-size;
}

What’s happening here is the font-size is looking for --container-fill-text-size and then using the CSS max() function to select the largest from step 10 in our fluid type scale or 15cqi, which is 15% of the current container’s inline size (width).

Because the container-fill-text custom element has container-type: inline-size; applied, it is officially our parent container for making such calculations.

The markup, with the custom element looks like this:

Code language
html

<container-fill-text multiplier="1.79">
  <h2 class="container-fill-text">
	  <span>Coffee &amp;</span><span>community.</span>
  </h2>
</container-fill-text>

Then we apply this little web component JS:

Code language
js

class ContainerFillText extends HTMLElement {
  constructor() {
    super();
  }

  connectedCallback() {
    if (this.textItems.length) {
      const maxLength = Math.max(...this.textItems);

      this.style.setProperty(
        '--container-fill-text-size',
        `${(100 * this.multiplier) / maxLength}cqi`
      );
    }
  }

  get textItems() {
    // Look for HTML children only first
    let children = [...this.childNodes].filter(x => x.tagName);

    if (children.length) {
      // Create a copy because we're about to override children
      const childParents = [...children];

      children = [];

      // Loop each child parent and spread their children so we can find the largest nested child
      childParents.forEach(x => {
        children = [...children, ...[...x.childNodes]];
      });
    }
    // Text has been added only so we need to grab that instead
    else {
      children = [...this.childNodes];
    }

    return children.length ? children.map(x => x.textContent.length) : '';
  }

  get multiplier() {
    return parseFloat(this.getAttribute('multiplier') || '2', 10);
  }
}

customElements.define('container-fill-text', ContainerFillText);

What happens here is the component looks for child nodes, which in our case is a <h2>. It then looks to see if that has children, which in our case is two <span> elements.

The reason we break the content into “lines” is because we don’t want one line of text to stretch, but rather, better typeset text that scales consistently.

Code language
js

connectedCallback() {
  if (this.textItems.length) {
    const maxLength = Math.max(...this.textItems);

    this.style.setProperty(
      '--container-fill-text-size',
      `${(100 * this.multiplier) / maxLength}cqi`
    );
  }
}

To do that, the component calculates the length of each “line”, selecting the largest of those values, then in the above little snippet, converting that into a cqi value which sets the --container-fill-text-size custom property that our CSS is already looking for.

I haven’t been happy with this solution though. The main reason is that we’re using a multiplier property — AKA an abstracted magic number — which is a massive code smell. This is currently set to the ever so easy to remember 1.79 😑. But, like I said, it is a compromise with a designer. Y’know, real world stuff we have to navigate.

Demo

It works pretty well, because of the multiplier though, getting it just right is painful. Very smelly code!

Working with Roman’s approach insteadpermalink

As soon as Roman published their approach, I knew it would be a better setup than what I already had. It took me a while to get my head around, but I’ve tweaked their code a bit to match our context well.

First, here’s the markup:

Code language
html

<h2 class="container-fill-text">
  <span class="container-fill-text__container">
    <span class="container-fill-text__display">Coffee &amp;<br>community.</span>
  </span>
  <span class="container-fill-text__reference" aria-hidden="true">Coffee &amp;<br>community.</span>
</h2>

I know, I know, there’s a lot of HTML and repeated content, which Roman explains well in their article. But, I’ve seen worse and we’re hiding the duplicate from assistive technology with aria-hidden="true". I’ll take extra HTML over JavaScript any day too because it’s much less likely to break!

Code language
css

.container-fill-text {
  --container-fill-text-captured-length: initial;

  display: flex;
  container-type: inline-size;

  /* Overrides a global style on headings */
  max-width: unset;

  line-height: var(--leading-micro);
  font-weight: var(--font-black);
}

.container-fill-text__reference {
  visibility: hidden;
}

.container-fill-text__container {
  --container-fill-text-captured-length: 100cqi;
  --container-fill-text-available-space: var(--container-fill-text-captured-length);

  flex-grow: 1;
  container-type: inline-size;
}

.container-fill-text__display {
  --container-fill-text-captured-length: 100cqi;
  --container-fill-text-ratio: tan(
    atan2(
      var(--container-fill-text-available-space),
      var(--container-fill-text-available-space) - var(--container-fill-text-captured-length)
    )
  );

  display: block;

  /* AKA, width */
  inline-size: var(--container-fill-text-available-space);

  /* Apply the calculated size with sensible fallbacks for no support */
  font-size: var(--size-step-11);

  /* The initial fallback value for no support now becomes our minimum clamp value */
  font-size: clamp(
    var(--size-step-11),
    1em * var(--container-fill-text-ratio),
    var(--container-fill-text-max-font-size, infinity * 1px)
  );
}

@property --container-fill-text-captured-length {
  syntax: '<length>';
  initial-value: 0px;
  inherits: true;
}

The first difference of my version vs Roman’s is I’m using classes to improve readability. Combinator selectors and the use of the universal selector can be complex for folks to understand.

I’ve also removed the use of --support-sentinal which powers a solid fallback experience for where the use of @property is not yet supported (it’s in baseline FYI).

What I’ve done differently here is I’m using the power of the cascade to set that default value because I want to take back a little bit of control by using a sensible step of our fluid type scale: var(--size-step-11).

Code language
css

/* The initial fallback value for no support now becomes our minimum clamp value */
font-size: clamp(
  var(--size-step-11),
  1em * var(--container-fill-text-ratio),
  var(--container-fill-text-max-font-size, infinity * 1px)
);

Let’s zoom in to the font-size calculation. What I’m doing here is first, setting the default value as our minimum size of the calculated clamp() output. This means I want the text to be at least step 11 on our fluid type scale.

The middle part of clamp() is what I like to call the ideal size. This is where our computed --container-fill-text-ratio that Roman so cleverly calculated using CSS math functions comes in to play. It’s multiplied by 1em so we don’t negatively affect a user’s ability to zoom text. If we just use viewport or container units as a user zooms, the container gets larger, which as you can guess sets us up for a bad time.

Finally, the last part of clamp is the maximum size. We’re looking for a --container-fill-text-max-font-size custom property, which if not set, falls back to infinity * 1px which is essentially a limitless size.

Demo

Wrapping uppermalink

I know this is not an ideal design pattern but life is also not ideal, let’s be honest with ourselves. This implementation I think is pretty decent though. When you’ve got to build not ideal stuff, the least you can do is everything in your power to impact users in the least negative way possible.

On the plus side, we’ve all learned some cool stuff!

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter