Modern CSS is fun
Published on
I’ve been doing a bunch of CSS tweaking recently, and keep being surprised by how nice modern CSS is to work with. As someone grey-haired enough to remember writing HTML without CSS, it’s amazing to think how far along web technology has come1. I wanted to demonstrate some of the handy bits and pieces I’ve used recently.
:has
The :has selector allows you to effectively query for child elements. While a span will match a <span> within an <a>, a:has(span) will match an <a> that contains a <span>. This really shines when combined with more complex selectors, for example:
input {
border-radius: var(--border-radius);
&:has(+ .results:not(:empty)) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
This is part of the styling for my film search box. When the search box is presented initially it has fully rounded corners. But when it is followed by a non-empty results element, it removes the rounding on the bottom corners so that the border continues in a straight line down into the results. You can see it in action below; just enter a few characters (like “the”) to get some results:
You could achieve the same effect by having JavaScript add a class to the <input> element, but I’ll take a CSS solution over a JavaScript solution any day.
Nested rules
You probably spotted this in the example above. It’s what finally made me switch from SCSS to plain CSS. If you have a rule for .foo and a rule for .foo .bar you can just nest them. Not only does it save repeating yourself an awful lot, it keeps everything organised nicely. For example, the CSS for my film list embeds looks like this:
a.film-list {
display: grid;
grid-template-areas: "header images" "description images" "meta images";
grid-template-columns: 1fr auto;
grid-template-rows: auto 1fr auto;
row-gap: var(--small-space);
@media (width <= 800px) {
grid-template-areas: "header" "description" "images" "meta";
grid-template-columns: 1fr;
grid-template-rows: auto auto auto auto;
}
& + & {
margin-top: var(--medium-space);
}
h3 {
font-size: var(--font-size-xxlarge);
}
.description {
/* ... */
}
}
Everything is wrapped up in a single bundle, including the @media rules for changing the layout on smaller screens, the lovely little & + & rule that adds some extra margin if there are two lists in a row, etc. The & syntax refers to the parent selector, so & + & in this case is the same as a.film-list + a.film-list: it’s very handy! The best part about this is that it’s the exact same syntax as SCSS, so converting is easy2. The styled element looks like this:
Watched films ranked




Every film I’ve watched since I started logging, ranked.
Obviously super subjective, and subject to change often and arbitrarily.
250 films
Media range syntax
Once again, you might have spotted this in the previous snippet. I’m not deliberately teasing things, I promise! Back in the day, you did media queries like so:
@media (min-width: 1000px) and (max-width: 2000px) {
/* some rules that work for screens at least 1000px wide and at most 2000px wide */
}
I always hated this syntax. I always got muddled up as to whether I wanted “min” or “max”, and whether they were inclusive or not. It’s easy to reason through, but it never came naturally. Fortunately you can now just use ranges:
@media (1000px <= width <= 2000px) {
/* some rules that work for screens at least 1000px wide and at most 2000px wide */
}
I find this style so much easier to write and understand.
Anchor positioning
Trying to dynamically position one element next to another used to exclusively fall within the purview of JavaScript. Fortunately anchor positioning fixes all this. I use this in the film search field I showed above:
.film-search {
input {
anchor-name: --film-search-box;
}
.results {
position: absolute;
position-anchor: --film-search-box;
position-area: bottom center;
position-visibility: always;
width: anchor-size();
}
}
This does lots of fun things. The .results element is anchored to the input field (via the --film-search-box name), and it’s positioned on the bottom,
directly under the anchor. The position-area rule describes a 3x3 grid, with the anchor at the center, so top left would put it diagonally adjacent to
the anchor. Next, position-visibility keeps the .results element visible when the anchor isn’t; otherwise when you scroll the anchor off the screen
the results would immediately vanish. Finally, the special anchor-size() function makes the .results element take up the same exact width as the anchor.
All-in-all, this puts the results exactly where I want them, without having to deal with any JavaScript at all. I can see how it’d also be amazingly useful trying to do tooltips or other forms of ‘floating’ content too. You can even leave the positioning up to the browser, giving it hints about which order to try, or what property to optimise for (e.g. you can say “anchor to the left or right, whichever has more horizontal space”).
attr() function
This one blew my mind a little. In some of the elements on the site, I’ve added a little label to the top. It looks something like this:
It’s meant to just be a visual flourish, not a semantic title or anything, so I initially just added a ::before selector to each element, customising the
content to have the right value. I didn’t like the duplication, though. Now, instead, I do this:
&::before {
content: attr(data-title);
}
Each component defines its title in a data attribute: <div class="raised-box" data-title="an example">, and the attr function plucks it out and
puts it in the content rule. It’s worth noting that this is only widely supported for the content rule, you can’t yet use it for colours or dimensions
or other things. It still feels a bit magical, though. Like you’re making a reusable, customisable component with just HTML and CSS.
layers
I’d come across layers a few times, but I never found a need for them. It seemed like something you’d only really need with complicated design systems, or something. Then I had a problem and layers were the perfect solution!
The headings on this site have a whole bunch of CSS attached to them. Currently they look a bit like blue painter’s tape with handwriting on them.
This style is applied to all headers, and then the various places that don’t want it had to manually reset everything. I got fed up with that, so
decided to add a plain-header class which would “disable” the extra styling:
h2,
h3,
h4,
h5,
h6 {
/* common styles for all headers, regardless */
font-family: "Chris Hand", sans-serif;
font-size: var(--font-size-xxlarge);
color: var(--text-highlight-colour);
margin: var(--medium-space) 0;
line-height: 1;
&:not(.plain-header) {
/* do fancy stuff if it _doesn't_ have the .plain-header class */
transform: rotate(-0.15deg);
padding: var(--smedium-space);
font-weight: 800;
filter: drop-shadow(3px 3px 5px rgb(0, 0, 0));
a {
background: none;
text-decoration-skip-ink: auto;
}
}
}
This might seem a bit backwards but I want the default to be styled, as those are far more common. I added this change, sprinkled around some “plain-header” classes, tidied up a lot of duplicate CSS, and things mostly worked. Mostly. Some things stopped working. In a few places the headers had slight tweaks, and they stopped working entirely.
It turns out in moving the rules from a plain h2 selector to a h2:not(.plain-header), I’d made them more specific.
Previously a selector like h2.special would have been more specific, so its properties would override those from the
less specific h2 selector. My first reaction was to try and hack around it. Changing all the overrides to
body h2.special would make them more specific again, but I didn’t want to have to remember to do that forever more.
Instead, I defined some custom layers, and put the header definitions in one:
@layer reset, links, headings
@layer headings {
h2,
h3 {
/* etc */
}
}
The first @layer rule defines some layers that will be handled in a specific order: first the “reset” layer, then
the “links” layer, then the “headings” layer, and then anything not in a layer after that. Rules in one layer don’t
have to worry about beating the specificity of rules in another layer, because they’re handled separately.
Note that I couldn’t just put the headings in a layer and call it a day: their rules have to come after the CSS reset,
and after the normal link styles, as headers have a bit of extra styling for nested links. If the CSS reset weren’t in
a layer, then the * { margin: 0; padding: 0; } type reset would apply over the rules from the headings layer.
Using layers is definitely a bit fiddly, and is not necessary for a lot of sites, but it’s a much cleaner alternative to ugly specificity hacking.
Things I’m looking forward to
There are a few things that aren’t (widely) available yet, that particularly interest me:
sibling-index() and sibling-count()
These new functions are available in WebKit and Blink based browsers, but not Firefox. They give you the index of the element within its siblings, or the count of siblings. For the film list component above, I currently have this abomination for the overlapping posters:
&:nth-child(1) {
z-index: 5;
left: 0;
}
&:nth-child(2) {
z-index: 4;
left: calc(133px * var(--overlap) * 1);
}
&:nth-child(3) {
z-index: 3;
left: calc(133px * var(--overlap) * 2);
}
&:nth-child(4) {
z-index: 2;
left: calc(133px * var(--overlap) * 3);
}
&:nth-child(5) {
z-index: 1;
left: calc(133px * var(--overlap) * 4);
}
These could be replaced with something like:
z-index: calc(1 + sibling-count() - sibling-index());
left: calc(133px * var(--overlap) * (sibling-index() - 1));
As an added bonus, it would scale to any number of posters. index being 1-based not 0-based is a
bit unfortunate as it makes using it in calculations awkward (see 1 offset in both of those rules!),
but it’s leagues better than writing 5 separate rules.
random()
Currently only available in Safari. There are a few places where I’d like to have slight random variations of the style. Things like elements that are rotated slightly for aesthetics; they look a bit silly if they’re all identically positioned. I also randomise the icons and positions of my rating stars to break up the visual monotony. Here are a few examples:
Currently I do the stars by hardcoding a bunch of classes and having the backend randomly apply one when it generates the markup for stars. Being able to do it in CSS would be great, though.
Mixins
This one’s so far off it’s not even listed on caniuse.com yet. Mixins are another handy feature of SCSS, that allow you to define reusable blocks of rules, then import them when needed:
@mixin fancy-background {
background: /*...*/;
}
.some-element {
@include fancy-background;
}
#other-element {
@include fancy-background;
}
This would be particularly useful if you can’t easily control the markup to add classes to everything. For example, my footnotes are rendered by a markdown plugin, and don’t easily have a way to add extra classes to them. To style it the same as another element, I currently duplicate a bunch of rules between them both. Mixins would allow me to define those rules once, and then import them in both places.
-
Aside from JavaScript, which seems to just endlessly reinvent new frameworks and ways to make the most sprawling dependency tree possible. ↩︎
-
It also means that if your website’s syntax highlighting library doesn’t seem to understand nested rules, you can just pretend your perfectly valid CSS is actually SCSS and it’ll magically work. Grumble, grumble. ↩︎
Related posts
Monthly Meanderings: February 2026
It doesn’t feel like a whole month has gone past since I wrote the last instalment of Monthly Meanderings, even allowing for how short a month February is. For more context on this series, you can check out the introduction to the first edition. Website updates I only wrote one new blog post this month: Just a nod, which is about the “nod” button I added to the bottom of most pag...
Monthly Meanderings: January 2026
Welcome to the second edition of my monthly meanderings. For a bit of context, you can check out the introduction to the first edition. Website updates It’s been a pretty busy month for chameth.com. Three blog posts: The Meaning of Life — an entry into the IndieWeb carnival where I mostly review a book on Stoicism — Surge Protectors: Marketing vs Reality which is a dump of a rese...
Coming around on LLMs
For a long time I’ve been a sceptic of LLMs and how they’re being used and marketed. I tried ChatGPT when it first launched, and was totally underwhelmed. Don’t get me wrong: I find the technology damn impressive, but I just couldn’t see any use for it. Recently I’ve seen more and more comments along the lines of “people who criticise LLMs haven’t used the...
I've been doing a bunch of CSS tweaking recently, and keep being surprised by how nice modern CSS is to work with. As someone grey-haired enough to remember writing HTML _without_ CSS, it's amazing to...



