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.
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.
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 &</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.
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 &<br>community.</span> </span> <span class="container-fill-text__reference" aria-hidden="true">Coffee &<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.
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!