Imagine this scenario: A label renders different text for different states, but these strings are known statically, ahead of time. For example, the element in question may be a save status indicator in an app's header. Sometimes the label is long (Saving...), but other times it's short (Saved). The code for this might look something like what I've shown below; it's React, but even if you don't know React, you should be able to make sense of it at a high level:

<span className="save-status">
  {isSaving ? 'Saving...' : 'Saved'}
</span>

If the label were just sitting there by itself, this would be pretty harmless. But imagine if the label had adjacent siblings in a grid or flex layout. As the label's width changes, it'll cause an unwanted layout shift among its siblings. This is particularly noticeable at smaller breakpoints.

It's also worth noting that the lengths of these strings may vary greatly depending on the user's locale if your app is internationalized. This means that fixing this layout shift is not a simple matter of adding some arbitrary padding to the end of the label and calling it a day.

Instead, what we want is for the label to always render with a fixed width—namely, the width of its longest text, even if this means that there's some unused space left over when the label renders shorter text. But how can you do this if the text is dynamic?

You could use JavaScript to perform DOM measurements after UI changes (useLayoutEffect) and set a min width on the label, but this can run into problems with server-side rendering. It also feels over-engineered since you shouldn't have to use JavaScript to fix UI problems like this. Fortunately, it turns out that this can be done entirely with CSS thanks to the power of CSS grid.

Below is a Codepen demo for this tutorial:

Solution: Overlaying Children with CSS Grid

Rather than conditionally rendering one string at a time based on state, what if we always render both of the strings but visually hide the one that doesn't correspond to the current state? If we could somehow overlay the two strings but force them to participate in the layout, then the label would always assume the width of its longest text.

return (
  <span className="save-status">
    <span style={{ visibility: isSaving ? 'hidden' : 'visible' }}>Saved</span>
    <span style={{ visibility: isSaving ? 'visible' : 'hidden' }}>Saving...</span>
  </span>
)

One idea is to use absolute positioning, but that won't work. Absolutely positioned elements are removed from the document flow, so they don't contribute to the layout at all—and this is exactly the opposite of what we want. Transforms and other naive tricks don't work, either.

In an article titled Less Absolute Positioning With Modern CSS, Ahmad Shadeed explores an alternative to absolute positioning that still allows you to overlay content. Ryan Mulligan also wrote about this technique on CSS Tricks: Positioning Overlay Content with CSS Grid.

The trick involves using CSS grid to overlay children on top of one another, without removing them from the document flow. As a result, the layout's width is determined by the width of its longest text child. We have this markup (React):

return (
  <span className="save-status">
    <span style={{ visibility: isSaving ? 'hidden' : 'visible' }}>Saved</span>
    <span style={{ visibility: isSaving ? 'visible' : 'hidden' }}>Saving...</span>
  </span>
)

And we'll use this CSS:

/* make a grid with 1 row and 1 column */
.save-status {
  display: grid;
}
/* all children should span every row and column */
.save-status * {
  grid-area: 1 / -1;
}

grid-area: 1 / -1 says that the children should span all rows and all columns of the parent grid layout; -1 just refers to the last row/column. Effectively, this means that our text labels will be stacked on top of one another, but they will also continue to influence the width and height of the layout. This means that the element will always take on the width of its longest text.

Below is a screenshot of what that looks like from the demo; notice how the red outline of the label doesn't fully hug the text on the right side. This is because there is some space reserved for the longer text:

A layout with a thin black border around it to delineate its box model. A label is rendered inside the layout with its own border, in red. The label has some extra space before the end of its right border, indicating that it's reserved space for longer text. A button is positioned to the right of the label. Below the layout box is an enabled checkbox that reads 'Prevent layout shift.'

Whenever the text changes, our styles will kick in and hide the text that shouldn't be visible. But we do this in such a way that the hidden element still contributes to the layout. No more layout shifts, and no more JavaScript to solve a CSS bug!

Attributions

The photo used in this post's social media preview was taken by William Warby (Unsplash).

Comments

Post comment

This comment system is powered by the GitHub Issues API. You can learn more about how I built it or post a comment over on GitHub, and it'll show up below once you reload this page.

Loading...