Readable Selector Groups

Writing selectors for elements that share states or behaviors used to mean comma-separated lists that grew long and hard to scan. The :is() pseudo-class collapses those groups into a single, more natural expression:

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

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

Beyond readability, :is() also gives selector groups a specificity boost by taking the highest specificity among its arguments. This can simplify overrides in large stylesheets, though it does demand awareness of how specificity propagates through the selector.

Centering With Logical Properties

The classic margin: auto trick still works for centering elements in both axes of a horizontal writing mode. But it becomes verbose when you only need to center along one axis, since you have to reach for the individual margin-left and margin-right properties instead of the shorthand.

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

Logical properties solved this by giving us shorthands that target whole axes at once. The result is both concise and writing-mode aware:

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

That snippet is not only shorter than spelling out two physical margins, it also adapts automatically if the document flows in a different direction — say, a vertical writing mode — because it centers along the inline axis regardless of its orientation.

Writing-Mode Resilience

Logical properties have arguably done more to change how styles are authored than flashier features like custom properties or container queries. Before, supporting a right-to-left layout meant writing separate rules targeted at an attribute selector:

/* 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 */
}

With logical properties applied from the start, the layout simply follows the writing mode and overrides become unnecessary:

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

That shift removes a whole category of resets and conditional styles from a codebase, making the stylesheet smaller and less prone to drift when direction changes are introduced later.

Spacing Between Navigation Items

A common pattern — an unordered list of links in a <nav> that must render horizontally — used to demand several steps: changing the display of the list items, then adding margins to create separation.

<nav>
  <ul>
    <li><a href="https://www.smashingmagazine.com/products">Products</a></li>
    <li><a href="https://www.smashingmagazine.com/products">Services</a></li>
    <li><a href="https://www.smashingmagazine.com/products">Docs</a></li>
    <!-- etc. -->
  <ul>
</nav>
/* Traditional */
li {
  display: inline-block;
}

With logical properties, adding a margin at the end of each item in the inline direction is a one-liner:

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

But that leaves trailing space after the last item, which becomes a problem once another element sits next to the list. The :not() pseudo-class removes that edge case cleanly:

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

There is also an experimental margin-trim property that collapses such superfluous space at the parent level. It is promising but only supported in Safari for now, so it is not ready for production use.

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

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

Flexbox turns out to be the more practical answer. Turning the list into a flex container gives the desired horizontal flow and exposes the gap property, which applies space only between children — effectively giving the behavior of margin with margin-trim built in:

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

There is rarely a single correct way to style something. The best choice is whatever fits the mental model of how the layout should behave.

List Semantics

Another detail worth noting: removing list markers with list-style-type: none has been shown to strip the list of its implicit accessible role in Safari. Manuel Matuzović suggests an alternative that stays in CSS by using an empty quoted value instead:

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

That approach requires some additional testing across browsers to check for side effects, but it preserves the list semantics without touching the HTML.

Fixed Aspect Ratios

Before aspect-ratio, preserving an element's proportions was a convoluted affair. Fixed units like pixels worked only when the size was static:

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

For flexible sizes, percentages were unreliable because they resolve against the parent, not the element itself, and the workflow quickly turned into setting dimensions on a containing element just to control the child. The "Padding Hack" — zeroing the height and using padding to define the box — worked but demanded an unusual understanding of the box model.

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

With aspect-ratio, a square stays square while its width scales with its container. It is far simpler and has become a practical replacement for explicit sizing in many cases.

Card Hover States

Styling a card's hover state used to mean wrapping the entire card in an <a> and hooking into that wrapper. With :has(), now supported in all major browsers as of Firefox 121, the link can live naturally inside the card and trigger the hover style on its parent:

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

The alternative — targeting a child based on the wrapper's hover state — is not only more markup-heavy but harder to read:

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

Building Color Systems Without The Heavy Lifting

For years, defining a color palette meant hand-picking every shade and documenting it with a descriptive variable name. The classic approach looked something like this:

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

There is nothing wrong with writing colors this way, but it is manual and rigid. Each gray is a fixed hex value, disconnected from the others in any meaningful way. A more modern approach leans on CSS custom properties and lets you derive shades from a single base value:

/* 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 conversions are not exact, but they illustrate the point. The syntax may look more verbose at first, yet the payoff comes when you need to shift the entire palette. Change one variable, and all the derived shades update automatically. Rename that base variable to something like --grayscale-palette-base, and you can apply the same pattern across multiple color scales to build a coherent 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 */
}

Color architecture is a deep topic, and this is not a prescription for how every project should be structured. The takeaway is simpler: CSS now gives you native building blocks that previously required Sass or other preprocessors. The variables are there, the math functions are there, and the palette becomes a system rather than a list.

Keeping Line Lengths Honest

Two relatively new CSS features have made a real difference in typography work:

  • The ch unit for character-based sizing;
  • The text-wrap: balance property.

The ch unit is invaluable for setting container widths on long-form content. Research consistently points to an ideal line length somewhere between 50 and 75 characters. When font sizes shift with viewport or container queries, predicting character counts becomes a guessing game. Setting a maximum width in ch removes that uncertainty — the container simply never exceeds 75 characters per line, regardless of breakpoint, with no media queries required.

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

Headings present a different challenge. Writers do not always know the font size, container width, or writing mode that will apply at render time. The browser, however, has all of that information. The text-wrap: balance value lets the browser decide how to wrap heading text to avoid orphaned words and uneven line lengths.

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

Support is still incomplete — Safari has not shipped it yet — but this is safe to use as a progressive enhancement. There is no downside if the browser ignores it. A word of caution: applying it broadly to all text is not only a performance concern. The balance value is specced to ignore content longer than ten lines, and user agents may treat it as auto beyond that limit.

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

A related property, text-wrap: pretty, is still experimental. It aims to let the browser optimize line breaks at some performance cost, but support is even more limited than balance, and it has not been widely tested in the wild.

Features Still On The Horizon

The features above are the ones that have most changed how styles are written in late 2023. There are others that have been adopted only partially or are still waiting for wider support:

  • Cascade Layers, mostly used in CodePen demos so far;
  • Container Queries, applied in isolated cases;
  • The <selectmenu> element, which is technically HTML but extends CSS capabilities;
  • CSS Nesting, which is bound to become a regular part of the workflow.

The arrival of so many new capabilities has sparked debate about whether CSS now has too much surface area and whether the learning curve has become a barrier for newcomers. The question worth asking is not whether there is too much CSS, but whether these tools actually make stylesheets easier to read, maintain, and reason about. For many of the examples above, the answer appears to be yes.

Smashing Editorial