Proper websites, done properly

CSS (and how I'm writing it) in 2024

22 minute read time / 2692 words

In the last few years there have been some incredible steps forward in frontend web development, and none moreso than with CSS.

Background

CSS has been around a long time now (version 1 published on December 17th 1996!) but it's always felt like it's missing some really important parts. CSS preprocessors began to appear that would help us developer folks do nicer things, in a better way. Preprocessors allow you to write more complicated, and more programmatical CSS in some ways. You then 'compile' those files and get vanilla CSS files out of them.

This allowed developers to use variables, nesting, proper partial stylesheet importing, and more.

First came SASS, which arrived in 2006 and added functionality to CSS, but the syntax it used wasn't CSS. It was similar to HAML or YAML in that it was a syntax that relied on indentation instead of colons and curly braces. LESS arrived three years later and was a smaller set of functionlity than SASS, but kept the syntax and general structure of vanilla CSS.

Not long after, SCSS appeared, which did all the same things as SASS, but instead of using an indentation-based syntax, allowed any valid CSS document to be parsed by the SCSS preprocessor.

These tools were so popular, and so useful that many of the better functionality they provided would become part of the CSS standards.

In the last few years, a lot of that functionality has been implemented in all the major browsers and, heading into 2024, it means a much better developer and end-user experience.

Advancements

Variables

Variables allow you to define a value once, and just keep referring back to it when you need it. No more wondering which exact colour the grey you're meant to be using is - just set it once and use it by name every time. The best part about that is that if the colour value needs changing later, you can just change it in a single place, instead of ending up in some sort of search/replace hell.

The downside to preprocessed variables was that it compiled out into 'just CSS', and that meant that your variables were converted during compilation into their actual values.

The brilliant thing about native CSS variables is that you can programatically change their values with JavaScript if you need to. Now that might not sound like a particularly useful thing off the bat, but consider that you could set a base font size and line height for the document, and allow people to dynamically update those values to allow them larger or smaller font sizes on your site - on the fly! Perfect for accessibility options.

:root {
    --font-size: 1.5rem;
}

body {
    font-size: var(--font-size);
}
const rootElem = document.documentElement;

rootElem.style.setProperty('--font-size', '2rem');

Nesting

One of the single most useful things preprocessors brought was the ability to nest syntax, meaning better grouping of styles, easier organisation of code and less writing the same things over and over. Preprocessors would take your shiny code and compile it into standard, flat CSS.

But, now that native CSS nesting is available in all major browsers, we're almost at a point where CSS preprocessors are barely needed!

This is an incredible step forwards! The browser will parse and understand your nested CSS on the fly.

.site-head {
    background-color: var(--amazing-pink);

    .logo {
        inline-size: 100px;
        block-size: 64px;
    }
}

This would be understood in the browser as:

.site-head {
    background-color: var(--amazing-pink);
}

.site-head .logo {
    inline-size: 100px;
    block-size: 64px;
}

Media query ranges

Not only does this make media queries simpler and shorter to write, I think it's easier and quicker to understand them too. Previously, you'd need to do something like this:

@media screen and (min-width: 1024px) and (max-width: 1679px) {
    /* styles here */
}

But now you're able to do this:

@media (1024px < width < 1680px) {
    /* styles here */
}

Variable fonts

Consider an average website: you're likely to have at least a bold and a regular weight font on there. Now, consider a much bigger site with far more range and much more complicated branding. They might have light, regular, medium, semi-bold, bold and black font weights across the site.

For each weight, they're loading a whole font file.

This is where variable fonts come in. You load a single font file, and it contains all the font weights and other stylistic options too.

There's currently one big downside to this: the download size of a single good variable font tends to be larger than downloading several font weight files run through a sensible font generator that will strip out all the unnecessary glyphs you're unlikely to ever need.

As soon as a good, easy-to-use variable webfont generator shows up, variable fonts will be the defacto standard for web fonts. They're an amazing step forward for web typography, and the only thing holding me back from using them more is the file size issue. Considering how I pride myself on building very fast sites, I'm still opting for two or three more file requests at smaller sizes that one larger one. Not for much longer though, fingers crossed.

Grid

If you're ancient, like I am, you'll remember the Wild West days of the Internet. When ICQ/WinAmp/Napster were still a thing, and text messages still cost real money to send.

Back then layout options for developers consisted of two real paths:

  • Write everything using HTML tables - this meant that they'd reliably lay out in a single way, but in order to change a design in future you'd have to change most of your HTML as well.
  • CSS floats. This meant you could put things left or right, but that the containers they were in would cease to understand their height, causing other issues down the line.

Neither of these were great. Then came along flexbox. A way to layout blocks in either rows or columns with a consistent method, without using floats or tables at all. Wonderful. Well, sort of.

Flexbox is brilliant if you use it for what it's really meant to be used for. People might tell you it's for layout, but in all honesty, it feels like it's for a very specific layout function: Cards.

Imagine a product listing page, you have 4 products to a row, and many rows to a page. This is where flexbox works well.

But what was still missing was a way to get much better control over the placement of blocks and to be able to change that for different screen sizes.

Then came CSS grid. Imagine being able to almost draw out a layout in CSS, and tell it what blocks to put in which slots. And then being able to target different screen sizes with different layouts, including putting those blocks in a different order to suit the available screen space. Imagine being able to set different numbers of rows and columns too. This is what CSS grid brings to front end development.

.article {
    display: grid;
    gap: 12px;
    /*
     * i = image, t = title, d = description, l = link
     */
    grid-template-areas:
        "i i t t t t"
        "i i d d d d"
        "i i l l l l"
    ;
}

Even just having that bit of visualisation is brilliant - you can see the layout from the code without too much thought. And you can easily move things around when your screen size is too small to accomodate this grid.

@media (--mobile) {
    .article {
        display: grid;
        gap: 12px;
        /*
         * i = image, t = title, d = description, l = link
         */
        grid-template-areas:
            "i i i"
            "t t t"
            "d d d"
            "l l l"
        ;
    }
}

It's incredibly powerful and flexible, while also being fairly easy to pick up the basics. Like many things in CSS, the barrier to entry is low, but mastering it is very difficult. Grid does a lot more than this, and with subgrid on the horizon, it's about to get even crazier.

This site was rebuilt using grid (mostly), and I've even gone back to other sites and retro-fitted CSS grid layouts to them. It's a fantastic addition to CSS and browser support is great for it too.

In fact, grid is so useful that in some cases I've opted out of using Flexbox because grid just feels like it does the same job in some cases more easily. What's not to love?

Layers

Cascade layers feels a bit like one of those things that should have always been a thing. And now it is. Cascade layers allow you to assign styles to individual layers which in essence gives those a priority.

Have you ever worked on a big site, where the styles just organically grew over time into a messy beast that was hard to control or understand properly? Where you felt the shame of having to use !important to make something work because something further up the cascade that couldn't be changed was far more specific than what you're writing now? Nasty.

*Angelic chorus*. Well, now you can avoid that kind of thing altogether.

@layer settings, base, utilities, theme;

@layer settings {
    :root {
        --my-var: 2rem;
        --my-big-var: 3rem;
        --my-actual-var: 1.5rem;
    }
}

@layer base {
    body {
        font-size: var(--my-var);
    }
}

@layer theme {
    p {
        font-size: var(--my-actual-var);
    }
}

@layer utilities {
    p {
        font-size: var(--my-big-var);
    }
}

The CSS above would output a paragraph at 1.5rem, because our theme layer is the highest in the cascade. Adding a layer order, and assigning styles into layers means that they're automatically more important than others regardless of when they're loaded.

In this example, theme is our highest layer, and settings is our bottom layer. With cascade layers, any un-layered styles are the most important - so just bear this in mind!

@layer base, theme;

p {
    color: green;
}

@layer base {
    body {
        color: blue;
    }
}

@layer utilities {
    p {
        color: pink;
    }
}

In this example our paragraph of text would be green, regardless of the output order of the styles. Cascade layers add an extra level of control over your styles, and can help prevent legacy style issues from messing with your new stuff.

Containers

Containers and container queries are another thing that feels like they should always have been around. They're an obvious, spiritual successor (and compliment to) standard CSS media queries.

In the past you would change the style of something based purely on screen size, whether the document is for print or screen, whether the orientation was landscape or portrait, and more recently whether or not someone was using dark mode, or high contrast mode, or reduced motion mode.

The part that always felt missing was being able to style a component based on how much space it actually had. The hardest part of building really beautiful responsive sites was getting the right breakpoints in your media queries so you never really saw a screen size where anything looked just a little weird.

With container queries we can now do that. You can define something as being a container, name it, and then style it specifically on how wide it is, not by just the size of screen you are using!

I've started implementing containers right here on this site, and you can see them in the articles or recent work 'cards'. Once the card is too small, the image will move above the text - and it doesn't matter whether you're on a mobile, tablet or desktop. It just works based on its own size.

.recent-things--work {
    container: workitems / inline-size;
}

@container workitems (width >= 500px) {
    .recent-things--work .item {
        grid-template-columns: 120px repeat(3, 1fr);
        /*
        * c = client logo/name, d = description, t = tags
        */
        grid-template-areas:
            "c d d d"
        ;
    }
}

@container workitems (width < 500px) {
    .recent-things--work .item {
        grid-template-columns: 1fr 1fr;
        /*
        * c = client logo/name, d = description
        */
        grid-template-areas:
            "c c"
            "t t"
            "d d"
        ;
    }
}

There's also new units you can use alongside containers too. There's also work ongoing to allow you to use a container query to target elements based on their current style.

Code style

Code style is very important to me. Neat, clean, tidy code is more maintainable and more easily understandable by more people with less effort. I've spent 20 years figuring out how I like to write CSS and what I think works well. Sure, as the language is updated and more stuff appears I've altered and adjusted my style to accomodate this.

What a lot of developers do is write either mobile or desktop styles first, and then in media queries write the styles for the other.

What I do is write universal styles for a selector outside of a media query, and have a media query for each breakpoint that contains styles that only apply to that breakpoint. This way, your browser is overriding less styles during the rendering of the page. And sure, that might not make any big difference in the grand scheme of things, and might not really slow the browser down visibly. But there's something logical there that I like. It's more obvious at a glance what is global to that selector, and what is applied to it at which breakpoint. It feels more easily understandable.

Although we've been nesting styles for a number of years, I generally opt to only use nesting where it is really useful, and only when it remains 'shallow' enough.

Often you will find stylesheets that have a 'base selector' which is then extended on using nesting throughout a document. While this works, and it's an obvious use case for nesting I believe it actually makes understanding the code more difficult. I also think it encourages developers to write insanely deeply nested CSS selectors which are just painful to maintain.

.product-listing {
    .product {
        &-list {
            &__card {
                font-size: 1rem;

                &-title {
                    /* title styles */

                    &--alt {
                        /* styles */
                    }
                }

                &-link {
                    /* link styles */

                    &:hover,
                    &:focus {
                        /* styles */
                    }

                    &.is_active {
                        /* styles */

                        &:hover,
                        &:focus {
                            /* styles */
                        }
                    }
                }

                ...
            }
        }
    }
}

Just do yourself and everybody else a favour and only nest where it makes best sense to do so. Two or three levels of nesting is plenty enough for most things. This would be much easier to understand by using less nesting:

.product-list__card {
    font-size: 1rem;
}

.product-list__card-title {
    /* title styles */

    &--alt {
        /* styles */
    }
}

.product-list__card-link {
    /* link styles */

    &:hover,
    &:focus {
        /* styles */
    }

    &.is_active {
        /* styles */

        &:hover,
        &:focus {
            /* styles */
        }
    }
}

Generally, I would recommend to nest pseudo elements such as ::before and ::after, pseudo classes such as :hover and :focus, state classes (those you might alter with JS for mobile menus, other functionality) like .is_active or .is_open, etc.

If you're using modifier classes such as with the BEM methodology, sometimes it makes sense to nest those too.

.article-card {
    /* Styles that do not change across breakpoints */

    /*
     * No specific order enforced, but I tend to do roughly:
     *
     * Container settings/grid settings
     * Structure/positioning/box-model:
     *    display, position, margin, padding, width, height, box-sizing, overflow etc.
     * Typography:
     *    font-*, line-height, text-*, letter-spacing etc.
     * Cosmetic:
     *    color, background-*, border-*, animation, transition etc.
     * Native interaction:
     *    appearance, cursor, user-select, pointer-events etc.
     */

    &--large {
        /* Modifier styles that do not change across breakpoints */

        /*
         * Sometimes these will be broken out into their own rule declaration
         * to reduce nesting and reduce cognitive load
         */
    }

    &::before,
    &::after {
        /* Pseudo element things */
    }

    &:hover,
    &:focus {
        /* Hover and focus styles */
    }

    @media (--desktop) {
        /* Styles just for this breakpoint */

        &--large {
            /* Modifier styles just for this breakpoint */
        }
    }

    @media (--tablet) {
        /* Styles just for this breakpoint */
    }

    @media (--mobile) {
        /* Styles just for this breakpoint */
    }
}