A Few Ways CSS Is Easier To Write In 2023 — Smashing Magazine

We’re living in somewhat of a CSS renaissance with new features, techniques, experiments, and ideas coming at us to an extent we haven’t seen since “CSS3”. It’s easy to feel overwhelmed when your profession seems to be advancing at breakneck speed, but it’s just as easy to be excited and inspired by what’s possible today that wasn’t even one year ago. While the complexity of CSS seems to be an ongoing discussion, Geoff Graham considers the ways “modern” CSS in 2023 has actually made CSS “easier” to write.

A little while back, I poked at a number of “modern” CSS features and openly evaluated whether or not they have really influenced the way I write styles.

Spoiler alert: The answer is not much. Some, but not to the extent that the styles I write today would look foreign when held side-by-side with a stylesheet from two or three years ago.

That was a fun thought process but more academic than practicum. As I continue thinking about how I approach CSS today, I’m realizing that the differences are a lot more subtle than I may have expected — or have even noticed.

CSS has gotten easier to write than it is different to write.

And that’s not because of one hot screaming new feature that changes everything — say, Cascade Layers or new color spaces — but how many of the new features work together to make my styles more succinct, resilient, and even slightly defensive.

Let me explain.

Feature Panel

Efficient Style Groups

Here’s a quick hit. Rather than chaining :hover and :focus states together with comma separation, using the newer :is() pseudo-class makes it a more readable one-liner:

/* Tradition */
a:hover,
a:focus {
  /* Styles */
}

/* More readable */
a:is(:hover, :focus) {
  /* Styles */
}

I say “more readable” because it’s not exactly more efficient. I simply like how it reads as normal conversation: An anchor that is in hover or in focus is styled like this…

Of course, :is() can most definitely make for a more efficient selector. Rather than make up some crazy example, you can check out MDN’s example to see the efficiency powers of :is() and rejoice.

Centering

This is a classic example, right? The “traditional” approach for aligning an element in the center of its parent container was usually a no-brainer, so to speak. We reached for some variety of margin: auto to push an element from all sides inward until it sits plumb in the middle.

That’s still an extremely effective solution for centering, as the margin shorthand looks at every direction. But say we only need to work in the inline direction, as in left and right, when working in a default horizontal left-to-write writing mode. That’s where the “traditional” approach falls apart a bit.

/* Traditional */
margin-left: auto;
margin-right: auto;

Maybe “falls apart” is heavy-handed. It’s more that it requires dropping the versatile margin shorthand and reaching specifically for two of its constituent properties, adding up to one more line of overhead. But, thanks to the concept of logical properties, we get two more shorthands of the margin variety: one for the block direction and one for the inline direction. So, going back to a situation where centering only needs to happen in the inline direction, we now have this to keep things efficient:

/* Easier! */
margin-inline: auto;

And you know what else? The simple fact that this example makes the subtle transition from physical properties to logical ones means that this little snippet is both as equally efficient as throwing margin: auto out there and resilient to changes in the writing mode. If the page suddenly finds itself in a vertical right-to-left mode, it still holds up by automatically centering the element in the inline direction when the inline direction flows up and down rather than left and right.

Adjusting For Writing Modes, In General

I’ve already extolled the virtues of logical properties. They actually may influence how I write CSS today more than any other CSS feature since Flexbox and CSS Grid.

I certainly believe logical properties don’t get the credit they deserve, likely because document flow is a lot less exciting than, say, things like custom properties and container queries.

Traditionally, we might write one set of styles for whatever is the “normal” writing direction, then target the writing mode on the HTML level using [dir="rtl"] or whatever. Today, though, forget all that and use logical properties instead. That way, the layout follows the writing mode!

So, where we may normally need to reset a physical margin when changing writing modes like this:

/* Traditional */
body {
  margin-left: 1rem;
}

body[dir="rtl"] {
  margin-left: 0; /* reset left margin */
  margin-right: 1rem; /* apply to the right */
  text-align: right; /* push text to the other side */
}

… there’s no longer a need to rest things as long as we’re working with logical properties:

/* Much easier! */
body {
  margin-inline-start: 1rem;
}

Trimming Superfluous Spacing

Here’s another common pattern. I’m sure you’ve used an unordered list of links inside of a <nav> for the main or global navigation of a project.

<nav>
  <ul>
    <li><a href="/products">Products</a></li>
    <li><a href="/products">Services</a></li>
    <li><a href="/products">Docs</a></li>
    <!-- etc. -->
  <ul>
</nav>

And in those cases, I’m sure you’ve been asked to display those links side-by-side rather than allowing them to stack vertically as an unordered list is naturally wont to do. Some of us who have been writing styles for some years may have muscle memory for changing the display of those list items from default block-level elements into inline elements while preserving the box model properties of block elements:

/* Traditional */
li {
  display: inline-block;
}

You’re going to need space between those list items. After all, they no longer take up the full available width of their parent since inline-block elements are only as wide as the content they contain, plus whatever borders, padding, margin, and offsets we add. Traditionally, that meant reaching for margin as we do for centering, but only the constituent margin property that applies the margin in the inline direction we want, whether that is margin-left/margin-inline-start or margin-right/margin-inline-end.

Let’s assume we’re working with logical properties and want a margin at the end of the list of items in the inline direction:

/* Traditional */
li {
  display: inline-block;
  margin-inline-end: 1rem;
}

But wait! Now we have margin on all of the list items. There’s really no need for a margin on the last list item because, well, there are no other items after it.

Three styled links from left-to-right with extra margin highlighted on the last item.
(Large preview)

That may be cool in the vast majority of situations, but it leaves the layout susceptible. What if, later, we decide to display another element next to the <nav>? Suddenly, we’re dealing with superfluous spacing that might affect how we decide to style that new element. It’s a form of technical debt.

It would be better to clean that up and tackle spacing for reals without that worry. We could reach for a more modern feature like the :not() pseudo-class. That way, we can exclude the last list item from participating in the margin party.

/* A little more modern */
li {
  display: inline-block;
}
li:not(:last-of-type) {
  margin-inline-end: 1rem;
}

Even easier? Even more modern? We could reach for the margin-trim property, which, when applied to the parent element, chops off superfluous spacing like a good haircut, effectively collapsing margins that prevent the child elements from sitting flush with the parent’s edges.

/* Easier, more modern */
ul {
  margin-trim: inline-end;
}

li {
  display: inline-block;
  margin-inline-end: 1rem;
}

Before any pitchforks are raised, let’s note that margin-trim is experimental and only supported by Safari at the time I’m writing this. So, yes, this is bleeding-edge modern stuff and not exactly the sort of thing you want to ship straight to production. Just because something is “modern” doesn’t mean it’s the right tool for the job!

In fact, there’s probably an even better solution without all the caveats, and it’s been sitting right under our noses: Flexbox. Turning the unordered list into a flexible container overrides the default block-level flow of the list items without changing their display, giving us the side-by-side layout we want. Plus, we gain access to the gap property, which you might think of as margin with margin-trim built right in because it only applies space between the children rather than all sides of them.

/* Less modern, but even easier! */
ul {
  display: flex;
  gap: 1rem;
}

This is what I love about CSS. It’s poetic in the sense that there are many ways to say the same thing — some are more elegant than others — but the “best” approach is the one that fits your thinking model. Don’t let anyone tell you you’re wrong if the output is what you’re expecting.

Just because we’re on the topic of styling lists that don’t look like lists, it’s worth noting that the common task of removing list styles on both ordered and unordered lists (list-style-type: none) has a side effect in Safari that strips the list items of its default accessible role. One way to “fix” it (if you consider it a breaking change) is to add the role back in HTML a là <ul role="list>. Manuel Matuzović has another approach that allows us to stay in CSS by removing the list style type with a value of empty quotes:

ul {
  list-style-type: "";
}

I appreciate that Manuel not only shared the idea but has provided the results of light testing as well while noting that more testing might be needed to ensure it doesn’t introduce other latent consequences.

Maintaining Proportions

There’s no need to dwell on this one. We used to have very few options for maintaining an element’s physical proportions. For example, if you want a perfect square, you could rely on fixed pixel units explicitly declared on the element’s width and height:

/* Traditional */
height: 500px;
width: 500px;

Or, perhaps you need the element’s size to flex a bit, so you prefer relative units. In that case, something like percentages is difficult because a value like 50% is relative to the size of the element’s parent container rather than the element itself. The parent element then needs fixed dimensions or something else that’s completely predictable. It’s almost an infinite loop of trying to maintain the 1:1 proportion of one element by setting the proportion of another containing element.

The so-called “Padding Hack” sure was a clever workaround and not really much of a “hack” as much as a display of masterclass-level command of the CSS Box Model. Its origins date back to 2009, but Chris Coyier explained it nicely in 2017:

“If we force the height of the element to zero (`height: 0;`) and don’t have any borders, then the padding will be the only part of the box model affecting the height, and we’ll have our square.”

— Chris Coyier

Anyway, it took a lot of ingenious CSS to pull it off. Let’s hear it for the CSS Working Group, which came up with a much more elegant solution: an aspect-ratio property.

/* Easier! */
aspect-ratio: 1;
width: 50%;

Now, we have a perfect square no matter how the element’s width responds to its surroundings, providing us with an easier and more efficient ruleset that’s more resilient to change. I often find myself using aspect-ratio in place of an explicit height or width in my styles these days.

Card Hover Effects

Not really CSS-specific, but styling a hover effect on a card has traditionally been a convoluted process where we wrap the element in an <a> and hook into it to style the card accordingly on hover. But with :has() — now supported in all major browsers as of Firefox 121! — we can put the link in the card as a child how it should be and style the card as a parent element when it *has* hover.

.card:has(:hover, :focus) {
  /* Style away! */
}

That’s way super cool, awesome, and easier to read than, say:

a.card-link:hover > .card {
  /* Style what?! */
}

Creating And Maintaining Color Palettes

A long, long time ago, I shared how I name color variables in my Sass files. The point is that I defined variables with hexadecimal values, sort of like this in a more modern context using CSS variables instead of Sass:

/* Traditional */
:root {
  --black: #000;
  --gray-dark: #333;
  --gray-medium: #777;
  --gray-light: #ccc;
  --gray-lighter: #eaeaea;
  --white: #fff;
}

There’s nothing inherently wrong with this. Define colors how you want! But notice that what I was doing up there was manually setting a range of grayscale colors and doing so with inflexible color values. As you might have guessed by this point, there is a more efficient way to set this up so that it is much more maintainable and even easier to read.

/* Easier to maintain! */
:root {
  --primary-color: #000;
  --gray-dark: color-mix(in srgb, var(--primary-color), #fff 25%);
  --gray-medium: color-mix(in srgb, var(--primary-color), #fff 40%);
  --gray-light: color-mix(in srgb, var(--primary-color), #fff 60%);
  --gray-lighter: color-mix(in srgb, var(--primary-color), #fff 75%);
}

Those aren’t exactly 1:1 conversions. I’m too lazy to do it for real, but you get the idea, right? Right?! The “easier” way may *look* more complicated, but if you want to change the main color, update the --primary-color variable and call it a day.

Perhaps a better approach would be to change the name --primary-color to --grayscale-palette-base. This way, we can use the same sort of approach across many other color scales for a robust color system.

/* Easier to maintain! */
:root {
  /* Baseline Palette */
  --black: hsl(0 0% 0%);
  --white: hsl(0 0% 100%);
  --red: hsl(11 100% 55%);
  --orange: hsl(27 100% 49%);
  /* etc. */

  /* Grayscale Palette */
  --grayscale-base: var(--black);
  --grayscale-mix: var(--white);

  --gray-100: color-mix(in srgb, var(--grayscale-base), var(--grayscale-mix) 75%);
  --gray-200: color-mix(in srgb, var(--grayscale-base), var(--grayscale-mix) 60%);
  --gray-300: color-mix(in srgb, var(--grayscale-base), var(--grayscale-mix) 40%);
  --gray-400: color-mix(in srgb, var(--grayscale-base), var(--grayscale-mix) 25%);

  /* Red Palette */
  --red-base: var(--red);
  --red-mix: var(--white);

  --red-100: color-mix(in srgb, var(--red-base), var(--red-mix) 75%);
  /* etc. */

  /* Repeat as needed */
}

Managing color systems is a science unto itself, so please don’t take any of this as a prescription for how it’s done. The point is that we have easier ways to approach them these days, whereas we were forced to reach for non-CSS tooling to even get access to variables.

Managing Line Lengths

Two things that are pretty new to CSS that I’m absolutely loving:

  • Character length units (ch);
  • text-wrap: balance.

As far as the former goes, I love it for establishing the maximum width of containers, particularly those meant to hold long-form content. Conventional wisdom tells us that an ideal length for a line of text is somewhere between 50-75 characters per line, depending on your source. In a world where font sizing can adapt to the container size or the viewport size, predicting how many characters will wind up on a line is a guessing game with a moving target. But if we set the container to a maximum width that never exceeds 75 characters via the ch unit and a minimum width that fills most, if not all, of the containing width in smaller contexts, that’s no longer an issue, and we can ensure a comfortable reading space at any breakpoint — without media, to boot.

article {
  width: min(100%, 75ch);
}

Same sort of thing with headings. We don’t always have the information we need — font size, container size, writing mode, and so on — to produce a well-balanced heading. But you know who does? The browser! Using the new text-wrap: balance value lets the browser decide when to wrap text in a way that prevents orphaned words or grossly unbalanced line lengths in a multi-line heading. This is another one of those cases where we’re waiting on complete browser support (Safari, in this instance). Still, it’s also one of those things I’m comfortable dropping into production now as a progressive enhancement since there’s no negative consequence with or without it.

A word of caution, however, for those of you who may be tempted to apply this in a heavy-handed way across the board for all text:

/* 👎 */
* {
  text-wrap: balance;
}

Not only is that an un-performant decision, but the balance value is specced in a way that ignores any text that is longer than ten lines. The exact algorithm, according to the spec, is up to the user agent and could be treated as the auto value if the maximum number of lines is exceeded.

/* 👍 */
article:is(h1, h2, h3, h4, h5, h6) {
  text-wrap: balance;
}

text-wrap: pretty is another one in experimentation at the moment. It sounds like it’s similar to balance but in a way that allows the browser to sacrifice some performance gains for layout considerations. However, I have not played with it, and support for it is even more limited than balance.

How About You?

These are merely the things that CSS offers here in late 2023 that I feel are having the most influence on how I write styles today versus how I may have approached similar situations back in the day when, during writing, I had to walk uphill both ways to produce a stylesheet.

I can think of other features that I’ve used but haven’t fully adopted in my toolset. Those would include things like the following:

What say you? I know there was a period of time when some of us were openly questioning whether there’s “too much” CSS these days and opining that the learning curve for getting into CSS is becoming a difficult barrier to entry for new front-enders. What new features are you finding yourself using, and are they helping you write CSS in new and different ways that make your code easier to read and maintain or perhaps “re-learning” how you think about styles?

Smashing Editorial
(yk, il)