The “Other” C in CSS

Category Image 052

I think it’s worth listening to anything Sara Soueidan has to say. That’s especially true if she’s speaking at an event for the first time in four years, which was the case when she took the stage at CSS Day 2024 in Amsterdam. What I enjoy most about Sara is how she not only explains the why behind everything she presents but offers it in a way that makes me go “a-ha!” instead of “oh crap, I’m doing everything wrong.”

(Oh, and you should take her course on Practical Accessibility.)

Sara’s presentation, “The Other ‘C’ in CSS”, was published on YouTube just last week. It’s roughly 55 minutes of must-see points on the various ways CSS can, and does, impact accessibility. I began watching the presentation casually but quickly fired up a place where I could take thorough notes once I found myself ooo-ing and ahhh-ing along.

So, these are the things I took away from Sara’s presentation. Let me know if you’ve also taken notes so we can compare! Here we go, there’s a lot to take in.

Here’s the video

Yes, CSS affects accessibility

CSS changes more than the visual appearance of elements, whether we like it or not. More than that, its effects cascade down to HTML and the accessibility tree (accTree). And when we’re talking about the accTree, we’re referring to a list of objects that describes and defines accessible information about elements.

There are typically four main bits of info about an accTree object:

  • Role: what kind of thing is this? Most HTML elements map to ARIA roles, but not all of them.
  • Name: identifies the element in the user interface.
  • Description: how do we further describe the thing?
  • State: what is its current state? Announce it!

The browser provides interactive features — like checking a checkbox that updates and exposes the element’s information — so the user knows what happens following an interaction.

Accessibility tree objects may also contain properties and relationships, such as whether it is part of a group or labeled by another element.

Example: List semantics

CSS can affect an object’s accessible role, name, description, or even whether it is exposed in the accTree at all. As such, it can directly impact the screen reader announcement. We shared a while back how removing list-style affects list semantics, particularly in the case of Safari, and Sara explains its nuances.

/* Removes list role semantics in Safari */
/* Need to add aria-role=list */
ul {
  list-style: none;
}

/* Does not remove role semantics in Safari */
nav ul {
  list-style: none:
}

/* Removed unless specifically re-added in the markup */
ul:where([role="list"]) {
  list-style: none;
}

/* Preserves list semantics */
ul {
  list-style: "";
}

display: contents

CSS can completely remove the presence of an element from the accessibility tree. I took a screenshot from one of Sara’s slides but it’s just so darn helpful that I figured putting the info in a table would be more useful:

Exposed to a11y APIs?Keyboard accessible?Visually accessible (rendered)?Children exposed to a11y APIs?
display: none
visibility: hidden
opactity: 0 and filter: opacity(0)
clip-path: inset(100%)
position(off-canvas)
.visually-hidden
display: contents

The display: contents method does more than it’s supposed to. In short, we know that display controls the type of box an element generates. A value of none, for example, generates no box.

The contents value is sort of like none in that not box is generated. The difference is that it has no impact on the element’s children. In other words, declaring contents does not remove the element or its child elements from the accTree. More than that, there’s a current bug report saying that declaring contents in Firefox breaks the anchoring effect of an ID attribute attached to an element.

Eric Bailey says that using display: contents is considered harmful. If using it, the recommendation is to set it on a generic <div> instead of a semantically meaningful element. If we were to use it on a meaningful interactive element, it would be removed from the accTree, and its children would be bumped up to the next level in the DOM.

Visually hiding stuff

Many, many of us use some sort of .visibility-hidden class as a utility for hiding elements while allowing screenreaders to pick them up and announce the contents. TPGi has a great breakdown of the technique.

.visually-hidden:not(:focus):not(:active) {
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0); /* for IE only */
  clip-path: inset(50%);
  position: absolute;
  white-space: nowrap;
}

This is super close to what I personally use in my work, but the two :not() statements were new to me and threw me for a loop. What they do is make sure that the selector only applies when the element is neither focused nor activated.

It’s easy to slap this class on things we want to hide and call it a day. But we have to be careful and use it intentionally when the situation allows for us to hide but still announce an element. For example, we would not want to use this on interactive elements because those should be displayed at all times. If you’re interacting with something, we have to be able to see it. But for generic text stuff, all good. Skip to content links, too.

There’s an exception! We may want an animated checkbox and have to hide the native control’s appearance so that it remains hidden, even though CSS is styling it in a way that it is visible. We still have to account for the form control’s different states and how it is announced to assistive tech. For example, if we hide the native checkbox for a custom one by positioning it way off the screen, the assistive tech will not announce it on focus or activation. Better to absolutely position the checkbox over the custom one to get the interactive accessibility benefits.

Bottom line: Ask yourself whether an interactive element will become visible when it receives focus when deciding whether or not to use a .visually-hidden utility.

CSS and accessible names

The browser follows a specific process when it determines an element’s accessible name (accName):

  • First, it checks for aria-labelledby. If present, and if the ID in the attribute is a valid reference to an element on the page, it uses the reference’s element’s computed text as the element’s accessible name.
  • Otherwise, it checks for aria-label.
  • Otherwise, unless the element is marked with role="presentation" or role="none" (i.e., the element does not accept an accName anymore), the browser checks if the element can get its own name, which could happen in a few ways, including:
    • from an HTML elemnenty, such as alt or title (which is best on an <iframe>; otherwise, avoid),
    • from another element, like <label> or <legend>, or
    • from its contents.

At this point, Sara went into a brief (but wonderful) tangent on <button> semantics. Buttons are labelable elements and can get their accName by using an aria-label attribute, an aria-labelledby attribute, its contents, or even a <label> element.

ARIA takes precedence over HTML which is why we want to avoid it only where we have to. We can see the priorities and overrides for accessible names in DevTools under the Accessibility tab when inspecting elements.

DevTools exposing the accessibility tree of the document and aria attributes for a selected anchor element.

But note: the order of priority defined in the accName computation algorithm does not define the order of priority that you should follow when providing an accName to elements. The steps should like be reversed if anything. Prioritize native HTML!

CSS generated content

Avoid using CSS to create meaningful content. Here’s why:

<a href="#" class="info">CSS generated content</a>
.info::before {
  content: "ⓘ" / "Info: ";
  /* or */
  content: url('path-to-icon.svg') / "Info: ";
}

/* Contents: : Info: CSS generated content. */

But it’s more nuanced than that. For one, we’re unable to translate content generated by CSS into different languages, at least via automated tools. Another one: that content is gone if CSS is unavailable for whatever reason. I didn’t think this would ever be too big a concern until Sara reminded me that some contexts completely strip out CSS, like Safari’s Reader Mode (something I rely on practically every day, but wish I didn’t have to).

There are also edge cases where CSS generated content might be inaccessible, including in Forced Colors environments (read: color conflicts), or if a broken image is passed to the url() function (read: alt text of the image is not shown in place of the broken image, at least in most browsers, yet it still contributes to the accName, violating SC 2.5.3 Label in Name). Adrian Roselli’s article on the topic includes comprehensive test results of the new feature, showing different results.

Inline SVG is probably better! But we can also do this to help with icons that are meant to be decorative to not repeat redundant information. But it is inconsistent as far as browser implementation (but Sara says Safari gets it right).

/* like: <img src="icon.svg" alt=""> */
.icon {
  content: url('path/to/icon.svg') / "";
}

So, what can we do to help prevent awkward and inaccessible situations that use CSS generated content?

  • Avoid using CSS pseudo-elements for meaningful content — use HTML!
  • Hide decorative and redundant CSS content by giving it an empty alt text (when support is there and behavior is consistent).

CSS can completely strip an element of its accName…

…if the source of the name is hidden in a way that removes it from the accessibility tree.

For example, an <input> can get its accName from a <label>, but that label is hidden by CSS in a way that doesn’t expose it to a11y APIs. In other words, the <label> is no longer rendered and neither are its contents, so the input winds up with no accName.

Showing the HTML for a label-input pair and CSS that uses display: none to hide the label.

BUT! Per spec:

By default assistive technologies do not relay hidden information, but an author can explicitly override that and include hidden text as part of the accessible name or accessible description by using aria-labelledby or aria-describedby.

So, in this case, we can reuse the label even if it is hidden by tacking on aria-labelledby. We could use the .visually-hidden utility, but the label is still accessible and will continue to be announced.

Using aria-labelled by on an text form input with DevTools showing the input's accessible name which is pulled from the label element.

CSS does not affect the state of an element in the accTree

If we use a <button> to show/hide another element, for example, the <button> element state needs to expose that state. Content on hover or focus violates SC 1.4.13 which requires a way to dismiss the content. And users must be able to move their cursor away from the text and have it persist.

CSS-only modals using the checkbox hack are terrible because they don’t trap focus, don’t make the page content inert, and don’t manage keyboard focus (without JavaScript).

Popovers created with the Popover API are always non-modal. If you want to create a modal popover, a <dialog> is the right way to go. I’m enamored with Jhey Tompkins’s demo using the popover for a flyout navigation component, so much so that I used it in another article. But, using popover for modal-type stuff — including for something like a flyout nav — we still need to update the accessible states.

There’s much more to consider, from focus traps to inert content. But we can also consider removing the popover’s ::backdrop for fewer restrictions, like making background content inert or trapping focus. Then again, something like a popover-based flyout navigation violates SC 2.4.12 Focus Not Obscured if it covers or obscures another element with focus. So, yes, visibility is important for usability but we should shoot for better usability that goes beyond WCAG conformance. (Sara elaborates on this in a comment down below.)

So… close the popover when focus leaves it. Sara mentioned an article that Amit Sheen wrote for Smashing Magazine where it’d be wise to pay close attention to how a change is communicated to the user when a <select> menu <option> is selected to update colors on the page. That poses issues about SC 3.2.2 where something changes on input. When the user interacts with it, the user should know what’s going to happen.

Final thoughts

Yeah, let all that sink in. It feels good, right? Again, what I love most about Sara’s presentation (or any of them, for that matter) is that she isn’t pointing any condemning fingers at anyone. I care about oodles accessible experiences but know just how much I don’t know, and it’s practical stuff like this where I see clear connections to my work that can make me better.

I took one more note from Sara’s talk and didn’t quite know where to put it, but I think the conclusion makes sense because it’s a solid reminder that HTML, CSS, and, yes JavaScript, all have seats at the table and can each contribute positively to accessible experience:

  • Hacking around JavaScript with CSS can introduce accessible barriers. JavasScript is still useful and required for these things. Use the right tool for the job.

The “Other” C in CSS originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.

Creating a Cohesive User Experience Using HSL Colors in CSS

Category Image 052

We all know what importance colors hold in anything, whether it’s a website layout, image, video, or any other graphical element. In essence, color is a subjective experience that results from the interaction between light, the eye, and the brain. Adding colors to the website gives a new life to the whole layout and graphical elements. Nobody likes to visit web pages with white, black, and gray colors on them. Colors make the elements look more realistic and catchy to the human eye.

Not just theoretically, psychology also comes into play when we use colors on websites. It has been scientifically proven that a specific set of colors triggers particular emotions in the human brain, such as autumn colors like orange and yellow representing joy or happiness, red color to festive seasons, and blue viewed as calm and trustworthy. Besides, you must have noticed that many food companies often use red and yellow on their websites, pharmaceutical companies tend to use green on their sites, fitness companies sometimes use orange, and so on.

What Are CSS Container Style Queries Good For?

Category Image 052

We’ve relied on media queries for a long time in the responsive world of CSS but they have their share of limitations and have shifted focus more towards accessibility than responsiveness alone. This is where CSS Container Queries come in. They completely change how we approach responsiveness, shifting the paradigm away from a viewport-based mentality to one that is more considerate of a component’s context, such as its size or inline-size.

Querying elements by their dimensions is one of the two things that CSS Container Queries can do, and, in fact, we call these container size queries to help distinguish them from their ability to query against a component’s current styles. We call these container style queries.

Existing container query coverage has been largely focused on container size queries, which enjoy 90% global browser support at the time of this writing. Style queries, on the other hand, are only available behind a feature flag in Chrome 111+ and Safari Technology Preview.

The first question that comes to mind is What are these style query things? followed immediately by How do they work?. There are some nice primers on them that others have written, and they are worth checking out.

But the more interesting question about CSS Container Style Queries might actually be Why we should use them? The answer, as always, is nuanced and could simply be it depends. But I want to poke at style queries a little more deeply, not at the syntax level, but what exactly they are solving and what sort of use cases we would find ourselves reaching for them in our work if and when they gain browser support.

Why Container Queries

Talking purely about responsive design, media queries have simply fallen short in some Aspects, but I think the main one is that they are context-agnostic in the sense that they only consider the viewport size when applying styles without involving the size or dimensions of an element’s parent or the content it contains.

This usually isn’t a problem since we only have a main element that doesn’t share space with others along the x-axis, so we can style our content depending on the viewport’s dimensions. However, if we stuff an element into a smaller parent and maintain the same viewport, the media query doesn’t kick in when the content becomes cramped. This forces us to write and manage an entire set of media queries that target super-specific content breakpoints.

Container queries break this limitation and allow us to query much more than the viewport’s dimensions.

How Container Queries Generally Work

Container size queries work similarly to media queries but allow us to apply styles depending on the container’s properties and computed values. In short, they allow us to make style changes based on an element’s computed width or height regardless of the viewport. This sort of thing was once only possible with JavaScript or the ol’ jQuery, as this example shows.

As noted earlier, though, container queries can query an element’s styles in addition to its dimensions. In other words, container style queries can look at and track an element’s properties and apply styles to other elements when those properties meet certain conditions, such as when the element’s background-color is set to hsl(0 50% 50%).

That’s what we mean when talking about CSS Container Style Queries. It’s a proposed feature defined in the same CSS Containment Module Level 3 specification as CSS Container Size Queries — and one that’s currently unsupported by any major browser — so the difference between style and size queries can get a bit confusing as we’re technically talking about two related features under the same umbrella.

We’d do ourselves a favor to backtrack and first understand what a “container” is in the first place.

Containers

An element’s container is any ancestor with a containment context; it could be the element’s direct parent or perhaps a grandparent or great-grandparent.

A containment context means that a certain element can be used as a container for querying. Unofficially, you can say there are two types of containment context: size containment and style containment.

Size containment means we can query and track an element’s dimensions (i.e., aspect-ratio, block-size, height, inline-size, orientation, and width) with container size queries as long as it’s registered as a container. Tracking an element’s dimensions requires a little processing in the client. One or two elements are a breeze, but if we had to constantly track the dimensions of all elements — including resizing, scrolling, animations, and so on — it would be a huge performance hit. That’s why no element has size containment by default, and we have to manually register a size query with the CSS container-type property when we need it.

On the other hand, style containment lets us query and track the computed values of a container’s specific properties through container style queries. As it currently stands, we can only check for custom properties, e.g. --theme: dark, but soon we could check for an element’s computed background-color and display property values. Unlike size containment, we are checking for raw style properties before they are processed by the browser, alleviating performance and allowing all elements to have style containment by default.

Did you catch that? While size containment is something we manually register on an element, style containment is the default behavior of all elements. There’s no need to register a style container because all elements are style containers by default.

And how do we register a containment context? The easiest way is to use the container-type property. The container-type property will give an element a containment context and its three accepted values — normal, size, and inline-size — define which properties we can query from the container.

/* Size containment in the inline direction */
.parent {
  container-type: inline-size;
}

This example formally establishes a size containment. If we had done nothing at all, the .parent element is already a container with a style containment.

Size Containment

That last example illustrates size containment based on the element’s inline-size, which is a fancy way of saying its width. When we talk about normal document flow on the web, we’re talking about elements that flow in an inline direction and a block direction that corresponds to width and height, respectively, in a horizontal writing mode. If we were to rotate the writing mode so that it is vertical, then “inline” would refer to the height instead and “block” to the width.

Consider the following HTML:

<div class="cards-container">
  <ul class="cards">
    <li class="card"></li>
  </ul>
</div>

We could give the .cards-container element a containment context in the inline direction, allowing us to make changes to its descendants when its width becomes too small to properly display everything in the current layout. We keep the same syntax as in a normal media query but swap @media for @container

.cards-container {
  container-type: inline-size;
  }

  @container (width < 700px) {
  .cards {
    background-color: red;
  }
}

Container syntax works almost the same as media queries, so we can use the and, or, and not operators to chain different queries together to match multiple conditions.

@container (width < 700px) or (width > 1200px) {
  .cards {
    background-color: red;
  }
}

Elements in a size query look for the closest ancestor with size containment so we can apply changes to elements deeper in the DOM, like the .card element in our earlier example. If there is no size containment context, then the @container at-rule won’t have any effect.

/* 👎 
 * Apply styles based on the closest container, .cards-container
 */
@container (width < 700px) {
  .card {
    background-color: black;
  }
}

Just looking for the closest container is messy, so it’s good practice to name containers using the container-name property and then specifying which container we’re tracking in the container query just after the @container at-rule.

.cards-container {
  container-name: cardsContainer;
  container-type: inline-size;
}

@container cardsContainer (width < 700px) {
  .card {
    background-color: #000;
  }
}

We can use the shorthand container property to set the container name and type in a single declaration:

.cards-container {
  container: cardsContainer / inline-size;

  /* Equivalent to: */
  container-name: cardsContainer;
  container-type: inline-size;
}

The other container-type we can set is size, which works exactly like inline-size — only the containment context is both the inline and block directions. That means we can also query the container’s height sizing in addition to its width sizing.

/* When container is less than 700px wide */
@container (width < 700px) {
  .card {
    background-color: black;
  }
}

/* When container is less than 900px tall */
@container (height < 900px) {
  .card {
    background-color: white;
  }
}

And it’s worth noting here that if two separate (not chained) container rules match, the most specific selector wins, true to how the CSS Cascade works.

So far, we’ve touched on the concept of CSS Container Queries at its most basic. We define the type of containment we want on an element (we looked specifically at size containment) and then query that container accordingly.

Container Style Queries

The third value that is accepted by the container-type property is normal, and it sets style containment on an element. Both inline-size and size are stable across all major browsers, but normal is newer and only has modest support at the moment.

I consider normal a bit of an oddball because we don’t have to explicitly declare it on an element since all elements are style containers with style containment right out of the box. It’s possible you’ll never write it out yourself or see it in the wild.

.parent {
  /* Unnecessary */
  container-type: normal;
}

If you do write it or see it, it’s likely to undo size containment declared somewhere else. But even then, it’s possible to reset containment with the global initial or revert keywords.

.parent {
  /* All of these (re)set style containment */
  container-type: normal;
  container-type: initial;
  container-type: revert;
}

Let’s look at a simple and somewhat contrived example to get the point across. We can define a custom property in a container, say a --theme.

.cards-container {
  --theme: dark;
}

From here, we can check if the container has that desired property and, if it does, apply styles to its descendant elements. We can’t directly style the container since it could unleash an infinite loop of changing the styles and querying the styles.

.cards-container {
  --theme: dark;
}

@container style(--theme: dark) {
  .cards {
    background-color: black;
  }
}

See that style() function? In the future, we may want to check if an element has a max-width: 400px through a style query instead of checking if the element’s computed value is bigger than 400px in a size query. That’s why we use the style() wrapper to differentiate style queries from size queries.

/* Size query */
@container (width > 60ch) {
  .cards {
    flex-direction: column;
  }
}

/* Style query */
@container style(--theme: dark) {
  .cards {
    background-color: black;
  }
}

Both types of container queries look for the closest ancestor with a corresponding containment-type. In a style() query, it will always be the parent since all elements have style containment by default. In this case, the direct parent of the .cards element in our ongoing example is the .cards-container element. If we want to query non-direct parents, we will need the container-name property to differentiate between containers when making a query.

.cards-container {
  container-name: cardsContainer;
  --theme: dark;
}

@container cardsContainer style(--theme: dark) {
  .card {
    color: white;
  }
}
Weird and Confusing Things About Container Style Queries

Style queries are completely new and bring something never seen in CSS, so they are bound to have some confusing qualities as we wrap our heads around them — some that are completely intentional and well thought-out and some that are perhaps unintentional and may be updated in future versions of the specification.

Style and Size Containment Aren’t Mutually Exclusive

One intentional perk, for example, is that a container can have both size and style containment. No one would fault you for expecting that size and style containment are mutually exclusive concerns, so setting an element to something like container-type: inline-size would make all style queries useless.

However, another funny thing about container queries is that elements have style containment by default, and there isn’t really a way to remove it. Check out this next example:

.cards-container {
  container-type: inline-size;
  --theme: dark;
}

@container style(--theme: dark) {
  .card {
    background-color: black;
  }
}

@container (width < 700px) {
  .card {
    background-color: red;
  }
}

See that? We can still query the elements by style even when we explicitly set the container-type to inline-size. This seems contradictory at first, but it does make sense, considering that style and size queries are computed independently. It’s better this way since both queries don’t necessarily conflict with each other; a style query could change the colors in an element depending on a custom property, while a container query changes an element’s flex-direction when it gets too small for its contents.

But We Can Achieve the Same Thing With CSS Classes and IDs

Most container query guides and tutorials I’ve seen use similar examples to demonstrate the general concept, but I can’t stop thinking no matter how cool style queries are, we can achieve the same result using classes or IDs and with less boilerplate. Instead of passing the state as an inline style, we could simply add it as a class.

<ol>
  <li class="item first">
    <img src="..." alt="Roi's avatar" />
    <h2>Roi</h2>
  </li>
  <li class="item second"><!-- etc. --></li>
  <li class="item third"><!-- etc. --></li>
  <li class="item"><!-- etc. --></li>
  <li class="item"><!-- etc. --></li>
</ol>

Alternatively, we could add the position number directly inside an id so we don’t have to convert the number into a string:

<ol>
  <li class="item" id="item-1">
    <img src="..." alt="Roi's avatar" />
    <h2>Roi</h2>
  </li>
  <li class="item" id="item-2"><!-- etc. --></li>
  <li class="item" id="item-3"><!-- etc. --></li>
  <li class="item" id="item-4"><!-- etc. --></li>
  <li class="item" id="item-5"><!-- etc. --></li>
</ol>

Both of these approaches leave us with cleaner HTML than the container queries approach. With style queries, we have to wrap our elements inside a container — even if we don’t semantically need it — because of the fact that containers (rightly) are unable to style themselves.

We also have less boilerplate-y code on the CSS side:

#item-1 {
  background: linear-gradient(45deg, yellow, orange); 
}

#item-2 {
  background: linear-gradient(45deg, grey, white);
}

#item-3 {
  background: linear-gradient(45deg, brown, peru);
}

See the Pen Style Queries Use Case Replaced with Classes [forked] by Monknow.

As an aside, I know that using IDs as styling hooks is often viewed as a no-no, but that’s only because IDs must be unique in the sense that no two instances of the same ID are on the page at the same time. In this instance, there will never be more than one first-place, second-place, or third-place player on the page, making IDs a safe and appropriate choice in this situation. But, yes, we could also use some other type of selector, say a data-* attribute.

There is something that could add a lot of value to style queries: a range syntax for querying styles. This is an open feature that Miriam Suzanne proposed in 2023, the idea being that it queries numerical values using range comparisons just like size queries.

Imagine if we wanted to apply a light purple background color to the rest of the top ten players in the leaderboard example. Instead of adding a query for each position from four to ten, we could add a query that checks a range of values. The syntax is obviously not in the spec at this time, but let’s say it looks something like this just to push the point across:

/* Do not try this at home! */
@container leaderboard style(4 >= --position <= 10) {
  .item {
    background: linear-gradient(45deg, purple, fuchsia);
  }
}

In this fictional and hypothetical example, we’re:

  • Tracking a container called leaderboard,
  • Making a style() query against the container,
  • Evaluating the --position custom property,
  • Looking for a condition where the custom property is set to a value equal to a number that is greater than or equal to 4 and less than or equal to 10.
  • If the custom property is a value within that range, we set a player’s background color to a linear-gradient() that goes from purple to fuschia.

This is very cool, but if this kind of behavior is likely to be done using components in modern frameworks, like React or Vue, we could also set up a range in JavaScript and toggle on a .top-ten class when the condition is met.

See the Pen Style Ranged Queries Use Case Replaced with Classes [forked] by Monknow.

Sure, it’s great to see that we can do this sort of thing directly in CSS, but it’s also something with an existing well-established solution.

Separating Style Logic From Logic Logic

So far, style queries don’t seem to be the most convenient solution for the leaderboard use case we looked at, but I wouldn’t deem them useless solely because we can achieve the same thing with JavaScript. I am a big advocate of reaching for JavaScript only when necessary and only in sprinkles, but style queries, the ones where we can only check for custom properties, are most likely to be useful when paired with a UI framework where we can easily reach for JavaScript within a component. I have been using Astro an awful lot lately, and in that context, I don’t see why I would choose a style query over programmatically changing a class or ID.

However, a case can be made that implementing style logic inside a component is messy. Maybe we should keep the logic regarding styles in the CSS away from the rest of the logic logic, i.e., the stateful changes inside a component like conditional rendering or functions like useState and useEffect in React. The style logic would be the conditional checks we do to add or remove class names or IDs in order to change styles.

If we backtrack to our leaderboard example, checking a player’s position to apply different styles would be style logic. We could indeed check that a player’s leaderboard position is between four and ten using JavaScript to programmatically add a .top-ten class, but it would mean leaking our style logic into our component. In React (for familiarity, but it would be similar to other frameworks), the component may look like this:

const LeaderboardItem = ({position}) => {
  <li className={item ${position &gt;= 4 && position &lt;= 10 ? "top-ten" : ""}} id={item-${position}}>
    <img src="..." alt="Roi's avatar" />
    <h2>Roi</h2>
  </li>;
};

Besides this being ugly-looking code, adding the style logic in JSX can get messy. Meanwhile, style queries can pass the --position value to the styles and handle the logic directly in the CSS where it is being used.

const LeaderboardItem = ({position}) => {
  <li className="item" style={{"--position": position}}>
    <img src="..." alt="Roi's avatar" />
    <h2>Roi</h2>
  </li>;
};

Much cleaner, and I think this is closer to the value proposition of style queries. But at the same time, this example makes a large leap of assumption that we will get a range syntax for style queries at some point, which is not a done deal.

Conclusion

There are lots of teams working on making modern CSS better, and not all features have to be groundbreaking miraculous additions.

Size queries are definitely an upgrade from media queries for responsive design, but style queries appear to be more of a solution looking for a problem.

It simply doesn’t solve any specific issue or is better enough to replace other approaches, at least as far as I am aware.

Even if, in the future, style queries will be able to check for any property, that introduces a whole new can of worms where styles are capable of reacting to other styles. This seems exciting at first, but I can’t shake the feeling it would be unnecessary and even chaotic: styles reacting to styles, reacting to styles, and so on with an unnecessary side of boilerplate. I’d argue that a more prudent approach is to write all your styles declaratively together in one place.

Maybe it would be useful for web extensions (like Dark Reader) so they can better check styles in third-party websites? I can’t clearly see it. If you have any suggestions on how CSS Container Style Queries can be used to write better CSS that I may have overlooked, please let me know in the comments! I’d love to know how you’re thinking about them and the sorts of ways you imagine yourself using them in your work.

Chris’ Corner: Real World CSS

Category Image 035

I enjoyed Lee Robinson’s take on How I’m Writing CSS in 2024. Rather than jump right into tools and syntax, it starts with the user:

What does a great experience look like loading stylesheets when visiting a website?

  1. Stylesheets should load as fast as possible (small file sizes)
  2. Stylesheets should not re-download unless changed (proper caching headers)
  3. The page content should have minimal or no layout shift
  4. Fonts should load as fast as possible and minimize layout shift

Agreed! Number 3, and to some degree 4, are almost more in the JavaScript bucket than CSS, but it’s a good starter list. I’d add “The page styles shouldn’t interfere with default accessibility”.

Then, after those, the developer experience is considered:

How can the DX of the styling tools we use help us create a better UX?

  1. Prune unused styles, minify, and compress CSS for smaller file sizes
  2. Generate hashed file names to enable safe, immutable caching
  3. Bundle CSS files together to make fewer network requests
  4. Prevent naming collisions to avoid visual regressions

What about to help us write more maintainable, enjoyable CSS?

  1. Easy to delete styles when deleting corresponding UI code
  2. Easy to adhere to a design system or set of themes
  3. Editor feedback with TypeScript support, autocompletion, and linting
  4. Receive tooling feedback in-editor to prevent errors (type checking, linting)

I like how the DX concerns are about making things easier that the UX demands. I want all that stuff! Although I admit I still bristle at the idea of dealing with unused styles. It’s very hard to properly detect unused styles and I worry about tools making those decisions.

Lee’s ultimate recommendations are CSS Modules, Tailwind, or StyleX (or just vanilla CSS on simple stuff), and I feel like those feel fair based on his own journey and accomplish the things he laid out. I’m a fan of the CSS Modules approach myself. It’s largely vanilla CSS, but with great scoping built in, it couples to components nicely, and is so well established it’s everywhere you need it.


Speaking of writing CSS in the real world, Ahmad Shadeed did quite a deep dive of looking at the TechCrunch Layout and approaching it with modern techniques.

Sure, it’s just a three column layout, but the different columns have all sorts of different constraints. The first is in a fixed position, the main content has a maximum width but is otherwise fluid as well as contains nested grids. There is a maximum width overall too, with the third column involving absolute positioning. That’s without getting into the (five!) major breakpoints and footer complexities. If you’re into nerding out on CSS layout, Ahmad tackles it literally five different ways, ultimately landing on a nice CSS grid powered technique. He called it easy to implement, but looking at the column declarations I think it only looks easy to someone who was on his fifth iteration. 🤣. And that’s only half the article.


To think that Ahmad’s tackling of a complex layout, in the end, only boiled down to a few lines of CSS is rather incredible. CSS is certainly more powerful. But is it easier? Geoff Graham thinks yeah, it is a little easier to write actually, in some ways.

To name a few, grouping styles is easier, centering is easier, translation needs are easier, and spacing is easier. Geoff names more. And by easier, really truly easier in all ways. Less and more direct code that is easier to reason about and does what it says.


Roman Komarov outlines The Shrinkwrap Problem, which is maybe a little niche but certainly a very interesting layout situation. The deal is that if content wraps, the element essentially takes up all available width. Not that strange, but when you look at how a wrapped title looks with text-wrap: balance;, for example, it looks a little weird. A header might only take up half the space visually, yet still take up all the available space.

Roman goes really deep on this, with solutions that involve even new tech like anchor positioning which is an awfully weird thing to invoke just for this, but hey, needs are needs. Just when you think this is all far too much for such a niche thing, Roman gets to the use-cases which are actually pretty basic and straightforward. Things like chat bubbles where full-width bubbles would look awkward. Or decorations on either side of a header.


David Bushell has a fun and illuminating post about button-specific CSS styles.

Have you ever repeatedly tapped on a button only for the page to zoom in unexpectedly? Rewind and fast-forward buttons in an audio player for example. This unwanted side effect can be removed with touch-action.

There are four others in there that are all in the decent-chance-you-hadn’t-thought-of-it category.

Chris’ Corner: More Like Scalable Vector Goodness

Category Image 052

I’m going to do an SVG issue here, because I find that technology persistently interesting. It’s a bit of a superpower for front-end developers who know how it works and can leverage it when needed to pull of interesting effects. For example, this compelling line drawing scroll effect is powered by SVG features.

There have been some really cool SVG tools I’ve only just seen recently, and some great writing about SVG techniques. Warms my little heart to see SVG still being actively explored even as it sits rather dormant from a standards point of view.

Let’s start with some tools and resources, since those are easy to digest and if you really love one of them you’ll be all like thanks CodePen Spark, you’re a good newsletter and ya know that’s what we’re in it for.

Tech Icons

SVG icons tend to be single-color as a trend, but actual logos tend to involve brand colors and can often be multi-color. I like how it’s super easy to use, offering both downloads and quick copy-and-paste.

Durves

I can’t explain it but sometimes you need an SVG of a grid of dots that are waving. This allows you to control all the Aspects of that. Has some tearable cloth vibes.

svghub

Squiggles, scribbles, shapes and… other stuff.

I love this because they are the kind of things that are perfect for vector art, but that you don’t typically find in things like icon sets. One click to copy right to clipboard or download.

SVGMix

Big one! 193 Icon collections. I do like that they are grouped in collections, so in case you need a bunch of assets, there is a good chance they’ll go together aesthetically. I’m a big Noun Project guy, but find it isn’t quite as well organized into collections.

OK I suppose we’d better move on to some techniques and explanations.


SVG Gradients: Solving Curved Challenges

How do you get a color gradient to follow the path of SVG artwork? Michael Sydney Moore solved it by breaking up the art into smaller sections and applying gradients to each section.

This is an interesting contrast to another technique that Ksenia Kondrashova explains.

SVG viewBox

The viewBox on SVG is pretty simple really: it sets up the visible coordinate system where everything else is drawn. Interestingly, you can change it at any time, and it effectively acts as a camera, especially if you animate it.

Brad Woods has perhaps the best explanation of it I’ve ever seen, via an interactive post.

Making noisy SVGs

Turns out <feTurbulence> is up to the job of making a noise effect in SVG, but there is a little more to it to make it nice, as Daniel Immke writes up:

To create noise, I used the <feTurbulence> filter which is explicitly for generating artificial textures but required quite a bit of fiddling to get to my liking. Then, I had to use other filter effects to eliminate color variance and blend naturally with the fill color selected, and finally apply the filter to the circle.

Noise sometimes feels like the perfect way to chill out the mathematical sharpness of vector art.

Also — did you know there is a weird trick to make noise with CSS gradients?

Responsive SVGs

There is a technique in this post from Nils Binder where he stretches just a part of an SVG according to variable content elsewhere and I love it.

Speaking of responsive… did you know the illustration in Ethan’s original article was responsive in itself?

Making SVG Loading Spinners: An Interactive Guide

This is part of what makes SVG so attractive to me: simple primitives that all combine together to do elegant things. Here, to make a specific kind of fun spinner, Sébastien Noël uses

  1. <circle> with a stroke
  2. stroke-dasharray to control exactly how the stroke should be dashed
  3. stroke-linecap to control the nice look of the dashed parts
  4. stroke-dashoffet to control the position of the dashes
  5. @keyframe animation to animate the stroke-dasharray making it feel like a spinner.

Icon transcendence: customizing icons to complement fonts

This one is from the “I hope your client has a lot of money” files. I love the idea but it’s wild. The idea is that SVG icons could swap out to match the vibe of the font they are next to.

But by “swap out”, really, somehow, it’s the same source icon.

Although these icons look quite differently visually, they were actually crafted by using the single source icon you saw above as a reference. For each of the fonts here, we’ve modified that source icon, thus producing a custom icon that better matches the style and mood of each font:

Chris’ Corner: Complexity

Category Image 052

Have you seen The Grug Brained Developer? It’s an essay with a URL. It’s written like a caveman became a developer and put together a philosophy that is largely a rally against complexity. Cavemen have dumb simple brains, get it? It has good points, and I largely agree with it. The caveman angle is a clever quirk to get to read it and to stick in ol’ brain.

(If you find it hard to read, I have seen a “translation”).

I think it’s fairly easy to relate to what Grug means when it comes to back-end architecture. Sprawling systems with complex requirements, services talking to each other, APIs, third-party dependencies, data manipulation methods, etc. It’s not that front-end development doesn’t have its own complexities, but it’s less obvious to point at a bit of CSS and be like, that, that’s where the complexity got too much.

Is it over, say, 2000 lines of CSS? Is it over three levels of nested DOM per component, or 12 overall? Is selector specificity averages? Is it when browser support requirements go too far back? Is it when there are too many sources of input?

I’ve certainly known organizations that, without perhaps understanding or admitting it, are afraid of their CSS. Nobody wants to touch it because nobody is entirely sure if they are going to kick over a bar stool in another bar, as the meme goes.

Taylor Troesh on a recent Changelog podcast said:

If there is any part of your code base that is starting to scare you, it needs immediate attention. Things will just get worse if there is that one part of your code base that all of your engineers are afraid to touch, because it grows in disgustingness, everyone wants to just get in there and get out and it accumulates. You want to start thinking about how you can throw that away without breaking everything.

I don’t think Taylor was talking about CSS, but to me, it maps perfectly. CSS can get into a situation where developers are afraid to touch it. That’s one reason I’m bullish on CSS that is scoped (despite my hesitation on actual CSS @scope), like CSS modules and Shadow DOM. If you can edit CSS with reasonably high confidence that you’re not touching styles elsewhere unintentionally, that’s good.

I feel CSS nesting, which is now a native feature of CSS, helps this to some degree. Write a unique class, and everything you select in a nested way is scoped to that class without you having to repeat the class name. Plus it’s just kinda ergonomically nice. But there are rather significant gotcha’s, which admittedly I really had no idea about until reading Kilian Valkhof’s The gotchas of CSS Nesting. The order in which rules are applied can get funky, and the specificity can also be unexpected. One example from Killian:

main, #intro {
    & div {
        ...
    }
}

Ends up as:

:is(main, #intro) div { ... }

Which makes it go from 0,0,1 for main div to 1,0,1 making it vastly more specific.

I’m not sure if nesting gotchas is the ultimate example of CSS complexity gone wild, but it’s just one little potential poke. I remember in the Sass heyday that people would point to @extend as a complexity code smell and they weren’t wrong.

Perhaps thanks to the scale and pervasiveness of npm, piles of questionable third-party code is another cause of complexity concern on the front-end. Michelle Barker gets into this in Reducing Complexity in Front End Development where she’s ruminating on Jack Franklin’s recent talk at All Day Hey.

A quick look in our Node modules folder quickly exposes the web of complexity within, which few of us can really fathom. A single point of failure can bring down the entire stack, like the well-worn “house of cards” metaphor.

Reducing dependencies, I’ll go out on a limb and say, is nearly always “worth it” in the sense of saving future-you headaches. Or as grug says:

one day code base understandable and grug can get work done, everything good!

next day impossible: complexity demon spirit has entered code and very dangerous situation!

Chris’ Corner: Subgrid

Category Image 052

Chrome 117 went stable this past week. There is a website where you can see what the plan is for Chrome releases, by the way, which is handy when you care about such things.

Chrome releases a major version about once a month, and I usually don’t feel ultra compelled to write anything about it specifically. Rachel Andrew does a great job covering web platform updates each month on Web.dev, like this past New to the web platform in August.

I’m extra excited about this one, though, because it means subgrid has now shipped across all three major browsers. Chrome was the straggler here:

  • Firefox shipped subgrid on Dec 2, 2019.
  • Safari shipped subgrid on Sep 11, 2022.
  • Chrome shipped subgrid on Sep 12, 2023.

Caniuse is a great site for not only checking support but also seeing when versions shipped that have support.

Lest I type too many words without explaining what subgrid is… it’s a keyword that works with grid-template-columns and grid-template-rows that allow you to suck in the grid lines that pass through the element from the parent grid.

.parent {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
}
.child {
  grid-column: 2 / 4;
  display: grid;
  grid-template-columns: subgrid;
}

Does your browser support it? Probably, but it’s still good to check and to code around that check. Bramus has a Pen that’s a quicky check. The CSS feature @supports is up for the job:

output::after {
  content: "❌ Your browser does not support subgrid";
}

@supports(grid-template-rows: subgrid) {
  output::after {
    content: "✅ Your browser supports subgrid";
  }
}

Perhaps the most classic example is when you set card elements on the grid, and you want elements with the cards to line up according to “shared” grid lines. Jhey has a demo like that of the basics.

I’ve also played with the cards idea, which is perhaps even more obvious where there are natural lines, like background colors running into each other:

Sometimes my favorite use cases are little itty bitty tiny things that are otherwise annoying or impossible to pull off well. For example! The aligning off CSS counters on list items. See below how in the first example the content in the list items is ragged-left, but in the second example, nicely aligned. That happens in this case by using subgrid to make all those counters essentially share a column line from the parent list item grid.

That example and several more are from a video I did with Dave a little while ago looking at all sorts of uses for subgrid.

Another of my favorites? Lining up web forms that have variable length labels. That exactly the use case that Eric Meyer showcased when he said that subgrid is “considered essential” seven years ago before subgrid shipped. Eric might have been a little wrong as grid has proven to be pretty dang useful even without subgrid, but there is no doubt that it is even moreso now.

MORE VIDEOS, you say? Can do!

  • I think of Rachel Andrew as the One True CSS Layout Master and she’s got a whole talk dedicated to CSS subgrid, which gets deeper into the details. One little one you might want to know: subgrids inherit the parent grid’s gap, but doesn’t have to!
  • Kevin Powell did a series of videos he called “Subgrid Awareness Month” about a year ago. This one about consistent layouts is a good place to start. CSS grid itself has strong “control the layout from the parent” vibes (unlike flexbox), and subgrid really enhances those powers.

Create a Headless CMS Using OceanBase and TypeScript: A Step-By-Step Tutorial

Category Image 052

If you're planning to start a blog or showcase your products on a website, you have two main options. You could code everything from scratch using HTML, CSS, and JavaScript, creating databases and interfaces to manage your content. This, however, can be challenging if you're not a seasoned programmer. A more efficient alternative is to use a Content Management System (CMS), which provides you with the tools to manage your content and design your website effortlessly.

There are numerous CMSs available, each with its strengths. WordPress, the most popular CMS, is known for its user-friendly interface and vast plugin ecosystem. Joomla and Drupal offer more robust platforms for complex websites, though they require some technical expertise. For beginners, Squarespace and Wix are ideal for creating visually attractive websites without needing to code.

Switching It Up With HTML’s Latest Control

Category Image 052

The web is no stranger to taking HTML elements and transforming them to look, act, and feel like something completely different. A common example of this is the switch, or toggle, component. We would hide a checkbox beneath several layers of styles, define the ARIA role as “switch,” and then ship. However, this approach posed certain usability issues around indeterminate states and always felt rather icky. After all, as the saying goes, the best ARIA is no ARIA.

Well, there is new hope for a native HTML switch to catch on.

Safari Technology Preview (TP) 185 and Safari 17.4 released with an under-the-radar feature, a native HTML switch control. It evolves from the hidden-checkbox approach and aims to make the accessibility and usability of the control more consistent.

<!-- This will render a native checkbox --//>
<input type="checkbox" />

<!-- Add the switch attribute to render a switch element --//>
<input type="checkbox" switch />
<input type="checkbox" checked switch />

Communication is one Aspect of the control’s accessibility. Earlier in 2024, there were issues where the switch would not adjust to page zoom levels properly, leading to poor or broken visibility of the control. However, at the time I am writing this, Safari looks to have resolved these issues. Zooming retains the visual cohesion of the switch.

The switch attribute seems to take accessibility needs into consideration. However, this doesn’t prevent us from using it in inaccessible and unusable ways. As mentioned, mixing the required and indeterminate properties between switches and checkboxes can cause unexpected behavior for people trying to navigate the controls. Once again, Adrian sums things up nicely:

“The switch role does not allow mixed states. Ensure your switch never gets set to a mixed state; otherwise, well, problems.”

— Adrian Roselli

Internationalization (I18N): Which Way Is On?

Beyond the accessibility of the switch control, what happens when the switch interacts with different writing modes?

When creating the switch, we had to ensure the use of logical CSS to support different writing modes and directions. This is because a switch being in its right-most position (or inline ending edge) doesn’t mean “on” in some environments. In some languages — e.g., those that are written right-to-left — the left-most position (or inline starting edge) on the switch would likely imply the “on” state.

While we should be writing logical CSS by default now, the new switch control removes that need. This is because the control will adapt to its nearest writing-mode and direction properties. This means that in left-to-right environments, the switch’s right-most position will be its “on” state, and in right-to-left environments, its left-most position will be the “on” state.

See the Pen Safari Switch Control - Styling [forked] by @DanielYuschick.

Final Thoughts

As we continue to push the web forward, it’s natural for our tools to evolve alongside us. The switch control is a welcome addition to HTML for eliminating the checkbox hacks we’ve been resorting to for years.

That said, combining the checkbox and switch into a single input, while being convenient, does raise some concerns about potential markup combinations. Despite this, I believe this can ultimately be resolved with linters or by the browsers themselves under the hood.

Ultimately, having a native approach to switch components can make the accessibility and usability of the control far more consistent — assuming it’s ever supported and adopted for widespread use.

Chris’ Corner: More Surprising Powers of CSS

Category Image 052

Kilian Valkhof has a good one this week, You don’t need JavaScript for that, as part of the HTMLHell yearly Advent Calendar (of blog posts). He opens with the rule of least power:

Choose the least powerful language suitable for a given purpose.

That’s fun for us CSS nerds, because CSS is a pretty low language when it comes to building websites. (It doesn’t mean you should try to write an accordion component in Assembly.) It means, as he puts it:

On the web this means preferring HTML over CSS, and then CSS over JS.

If you’re at the JS level already, it means preferring JS over a JS framework or meta language. It’s not that those things aren’t valuable (Heck, one of the purposes of CodePen is making using language abstractions and library easier) it’s that, well, it’s just a better idea to go lower level. There is less to download, less to break, higher chances of the browser optimizing it, higher chances it will be accessible, higher chances it will last over time. That stuff matters.

Killian opens with a custom “toggle” component, and really, component is probably too strong a word. It’s just a styled HTML checkbox. It’s not even all that much CSS, and no JS is used at all, to make this:

While I was reading, where I thought Killian was going was using an <input type="checkbox"> actually turn on and off features on a website. That’s the kind of thing that feels like definite-JavaScript territory, and yet, because of the classic “Checkbox Hack” (e.g. using the :checked selector and selector combinators) we actually can control a lot of a website with just a checkbox.

The ability to do that, control a website with a checkbox, has increased dramatically now that we have :has() in CSS. For instance:

<body>
  ... literally anywhere deep in the bowels of the DOM ...
  <input class="feature" type="checkbox">

We don’t have to worry about where in the DOM this checkbox is anymore, we can style anything there like this now:

body:has([.feature:checked]) .literally-anything {
  /* do styles */
}

We can start the styling choices way up at the body level with :has(), looking for that checkbox (essentially a toggle), and style anything on the page on if it is checked or not. That feels extraordinarily powerful to me.


Speaking of the mental crossover between CSS and JavaScript, Yaphi Berhanu’s Write Better CSS By Borrowing Ideas From JavaScript Functions was interesting. CSS doesn’t actually have functions (but just wait for it!) but that doesn’t mean we can’t consider how our thinking about the code can relate. I liked the bit about considering too many parameters vs not enough parameters. Yaphi connects that idea too too many selectors with too many repeat declarations, and yeah I can get behind that.

But where my mind goes is taking that lesson and trying to apply it to Custom Properties. I have seen (and written!) CSS that just takes Custom Property usage just too far — to the point where the code feels harder to reason about and maintain, not easier.

Like if you were going to make a variable for an animation…

.do-animation {
  animation: 3s ease-in 1s infinite reverse both running slidein;
}

You could decide that the right way to make this controllable and re-suable is something like:

:root {
  --animation-duration: 3s;
  --animation-easing: ease-in;
  --animation-delay: 1s;
  --animation-direction: reverse;
  --animation-fill-mode: both;
  --animation-play-state: running;
  --animation-name: slidein;
}

.do-animation {
  animation: var(--animation-duration) var(--animation-easing) var(--animation-delay) var(--animation-direction) var(--animation-fill-mode) var(--animation-play-state) var(--animation-name);
}

Maybe you love that? But I think it’s already going too far. And it could easily go further, since you could have another whole set of variables that set default fallbacks, for example.

It depends on what you want to do, but if your goal is simply “reusability” then doing like…

:root {
  --brandAnimation: 3s ease-in 1s infinite reverse both running slidein;
}

And then using it when you needed it is closer to baby bear’s porridge.


Little reminder: don’t sleep on View Transitions.

We won’t know what Interop 2024 will do until January, but based on the amount of upvotes from the proposals, I think it stands a good chance.

Jeremy Keith has a well measured take in Add view transitions to your website. It’s got links to good resources and examples on using it, like Tyler’s Gaw’s great post. It ends with an update and warning about how maybe it’s not such a great idea because it may “poison the feature with legacy content”. Meaning if too many people do this, the powers that be will be hesitant to change anything, even for the better. I agree that would suck if such a choice is made, as I do think there is room for improvement (e.g. transition groups). I dunno though, I think because this stuff is literally behind a feature flag, that’s enough of an at your own risk warning. If they want to change something that breaks any code I’ve shipped, I’m cool with that. I know the stakes.

I think we’ll start seeing more interesting examples and experiences soon. And! Gotchas! Like Nic Chan’s View transitions and stacking context: Why does my CSS View Transition ignore z-index? It is confusing. It’s like the elements kinda stop being elements when that are being transitioned. They look like elements, but they are really temporary rasterized ghosts. You can still select them with special pseudo element selectors though, so you should be able to get done what you need.


There is this inherit complexity with doing the whole Dark Mode / Light Mode thing on websites. There is both a system-wide preference that you can choose to honor, and that’s a good idea. And it’s likely that a site implementing this also offers a UI toggle to set the theme. So that’s two separate bits of preference you need to deal with, and the code likely handles them separately.

Christopher Kirk-Nielsen’s A Future of Themes with CSS Container Style Queries gives us a good taste of this. Let’s jump right to code:

/* No theme has been set, or override set to light mode */
html:where(:not([data-theme])),
:root[data-theme=light] {
  --color: black;
  --background: antiquewhite;
  /* … and all your other "variables" */
}

/* Apply dark mode if user preferences call for it, and if the user hasn't selected a theme override */
@media (prefers-color-scheme: dark) {
  html:where(:not([data-theme])) {
    --color: ghostwhite;
    --background: midnightblue;
    /* … and all your other "variables" */
  }
}

/* Explicitly set the properties for the selected theme */
:root[data-theme=dark] {
  --color: ghostwhite;
  --background: midnightblue;
  /* … and all your other "variables" */
}

If you read though that I think you’ll see, you need @media to deal with system preferences, then the HTML attributes to deal with a direct-set preference, and one needs to overlap the other.

Enter Style Queries. What they really are is: “when an element has a certain custom property and value, also do this other stuff.” Christopher’s “future approach” using them is more code, but I’d agree that’s more clean and readable.

/* Optionally, we can define the theme variable */
@property --theme {
  syntax: '<custom-ident>'; /* We could list all the themes separated by a pipe character but this will do! */
  inherits: true;
  initial-value: light;
}

/* Assign the --theme property accordingly */
html:where(:not([data-theme])),
:root[data-theme=light] {
  --theme: light;
}

@media (prefers-color-scheme: dark) {
  html:where(:not([data-theme])) {
    --theme: dark;
  }
}

:root[data-theme=dark] {
  --theme: dark;
}

/* Then assign the custom properties based on the active theme */
@container style(--theme: light) {
  body {
    --color: black;
    --background: antiquewhite;
    /* … and all your other "variables" */
  }
}

@container style(--theme: dark) {
  body {
    --color: ghostwhite;
    --background: midnightblue;
    /* … and all your other "variables" */
  }
}

Check out Christopher’s article though, there is a lot more to go over.


Let’s end with a little good ol’ fashioned CSS enthusiasm.

For many, many years, creating high-quality websites largely meant that designers had to fight what felt like an uphill battle to somehow make their ideas work in browsers. At the same time, sentences like “I’m really sorry, but this solution you designed just can’t be done with CSS” […]

This has now changed to the opposite.

Want to emulate and confidently design a layout that leverages the potential of CSS Grid in any of the major design tools like Figma, Adobe XD, or Sketch? Not possible. 

Matthias Ott, The New CSS

And:

I still keep Sass around, as well as PostCSS, for things that CSS just wont ever be able to do (nor should it), but they’re fading into the background, rather than being at the forefront of my mind when writing styles. And for small, simple projects, I don’t use them at all. Just pure CSS. I haven’t found it lacking.

Jeff Sandberg, CSS is fun again