Modern CSS Fundamentals
Baseline
Baseline features are ones that are supported by all the major browsers (Chrome, Edge, Safari, and Firefox). Both the MDN web docs and caniuse show when a feature has reached either the Newly available or Widely available threshold.
Progressive Enhancement
Some new CSS features aren’t supported in all browsers, but are fantastic progressive enhancements.
In other words, they make things better in the browsers that support that feature, but they will not cause any issues if the feature is unsupported. A simple example of that is text-wrap: pretty
which prevents orphans in text. Early 2025 is only supported by Chromium browsers, but can safely be used.
Logical properties and values
CSS 2.1 and earlier had sized things according to the physical dimensions of the screen. Therefore we describe boxes as having a width and height, position items from the top and left, assign borders, margin, and padding to the top, right, bottom, left, etc. The Logical properties and values module defines mappings for these physical properties and values to their logical, or flow relative, counterparts — e.g., start
and end
as opposed to left
and right
, top
and bottom
.
A key concept of working with flow relative properties and values is the two dimensions of block and inline. CSS layout methods such as flexbox and grid layout use the concepts of block
and inline
rather than right
and left
/top
and bottom
when aligning items.
The inline dimension is the dimension along which a line of text runs in the writing mode in use. Therefore, in an English document with the text running horizontally left-to-right, or an Arabic document with the text running horizontally right-to-left, the inline dimension is horizontal. Switch to a vertical writing mode (e.g., a Japanese document) and the inline dimension is now vertical, as lines in that writing mode run vertically.
The block dimension is the other dimension, and the direction in which blocks — such as paragraphs — display one after the other. In English and Arabic, these run vertically, whereas in any vertical writing mode these run horizontally.
CSS Reset
CSS has a fair number of defaults. Many of these defaults are fine (though some are problematic, such as box-sizing
). It’s common customise almost everything, so it’s a common practice to “reset” our CSS to give us more of a blank slate to work from, where the default values won’t get in our way. Kevin provides a light-weight reset based on Andy Bell’s A (more) Modern CSS Reset.
CSS Cascade Layers
CSS Cascade layers allow us to create our own mini-cascade within the larger cascade that you’re already used to working with.
This can be useful for keeping our CSS well organised. Rolling your reset up as a layer is an elegant way to package it.
The @layer at-rule is used to define a cascade layer in one of three ways.
Why is this useful? Rules within a cascade layer cascade together, giving more control over the cascade. Styles that are not defined in a layer always override styles declared in named and anonymous layers.
The first way is to use a @layer
block at-rule to create a named cascade layer with the CSS rules for that layer inside, like so:
@layer utilities {
.padding-sm {
padding: 0.5rem;
}
.padding-lg {
padding: 0.8rem;
}
}
The second way is to use a @layer
statement at-rule to create one or more comma-separated named cascade layers without assigning any styles. This can be a single layer or multiple layers, like this:
@layer theme, layout, utilities;
As with declarations, the last layer wins if declarations are found in multiple layers. In the above, if a competing rule was found in theme
and utilities
, the one in utilities
would win and be applied.
The third way is to create an unnamed anonymous cascade layer using a @layer
block at-rule without including a layer name, like so:
@layer {
p {
margin-block: 1rem;
}
}
CSS Custom Properties (variables)
What a time to be alive.
Set using the @property
at-rule or by custom property syntax (e.g., --primary-color: blue;
). Custom properties are accessed using the CSS var()
function (e.g., color: var(--primary-color);
).
section {
--primary-color: blue;
}
The selector scopes where the custom property can be used. For this reason, a common practice is to define custom properties on the :root
pseudo-class, so that it can be referenced globally:
:root {
--primary-color: blue;
}
Colors
Colors and font-related values are the perfect use case for custom properties.
Establishing a consistent and meaningful naming system is key. While you could use names directly from the Figma file:
:root {
--white: #fff;
--primary: hsl(25, 88%, 66%);
--primary-light: hsl(25, 88%, 54%);
--brown-1: rgb(66, 61, 60);
}
There’s an inconsistent mix in naming and values. I like Kevin’s system, of using a color scale that goes from 100
to 900
, and use 500
as the “base” color, with higher numbers being lighter, and lower numbers being darker. This is also good for maintainability where a 450
can be wedged in as the design evolves.
:root {
--clr-green-400: #659477;
--clr-green-500: #3b8256;
--clr-green-600: #23402f;
}
Best practice to use a common representation system for example to HSL.
Typography
Self hosting is the way to go these days. Cross origin CDN requests don’t enjoy the client cache.
For best cross-browser compatibility, web font formats are a must (WOFF2, WOFF). Here is how you setup a basic @font-face
at-rule that defines the supported character ranges and a woff2
to woff
fallback.
@font-face {
font-family: "Outfit";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url(./fonts/Outfit-Variable-Latin.woff2)
url(./fonts/Outfit-Variable-Latin.woff) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
Naming, go shorthand, two character. So font-size
values will be --fs-*
, and font-family
ones will be --ff-*
, and so on.
Use a similar numbering system to the colors convention, with 400
being the anchor, the size the body is set to. Again gives little room for smaller sizes (less of a need for this in practice), and then a lot of larger sizes.
Never use pixels for sizing always use Rems.
rem
values were invented in order to sidestep the compounding problem, by specifying a font size in a relative fashion (to the root html element) without being affected by the size of the parent, thereby eliminating compounding.
Media queries and custom properties
One of the coolest traits of custom properties is they can be updated within media queries.
:root {
/* colors here */
--ff-heading: "Outfit", sans-serif;
--ff-body: "Fira Sans", sans-serif;
--fs-300: 0.875rem;
--fs-400: 1rem;
--fs-500: 1.125rem;
--fs-600: 1.25rem;
--fs-700: 1.5rem;
--fs-800: 2rem;
--fs-900: 3.75rem;
--fs-1000: 3.75rem;
@media (width > 760px) {
--fs-300: 0.875rem;
--fs-400: 1rem;
--fs-500: 1.25rem;
--fs-600: 1.5rem;
--fs-700: 2rem;
--fs-800: 3rem;
--fs-900: 5rem;
--fs-1000: 7.5rem;
}
}
Structured custom properties
The current properties like --clr-green-600: #23402f;
are known as primitives. A robust design system adds a semantic layer of properties, which describe the purpose of each, instead of the specific color that they are.
For example, for our orange text, instead of having to remember if we used the 400
or 500
version of our brand color, we just need to remember --text-accent
, or for a dark background, if we just need to remember --background-dark
.
:root {
/* primitives here */
--text-main: var(--clr-gray-100);
--text-high-contrast: var(--clr-white);
--text-brand: var(--clr-brand-500);
--text-brand-light: var(--clr-brand-400);
--background-accent-light: var(--clr-green-400);
--background-accent-main: var(--clr-green-500);
--background-accent-dark: var(--clr-green-600);
--background-extra-light: var(--clr-brown-500);
--background-light: var(--clr-brown-600);
--background-main: var(--clr-brown-700);
--background-dark: var(--clr-brown-800);
--background-extra-dark: var(--clr-brown-900);
--font-size-heading-sm: var(--fs-700);
--font-size-heading-regular: var(--fs-800);
--font-size-heading-lg: var(--fs-900);
--font-size-heading-xl: var(--fs-1000);
--font-size-sm: var(--fs-300);
--font-size-regular: var(--fs-400);
--font-size-md: var(--fs-500);
--font-size-lg: var(--fs-600);
--border-radius-1: 0.25rem;
--border-radius-2: 0.5rem;
--border-radius-3: 0.75rem;
}
It’s a good idea to look for commonality over a project, that would be handy as custom properties. These are things we don’t really need primitives for, and can jump right to the “semantic” names for.
Look for things that:
- Repeat themselves throughout the project in different places.
- I wouldn’t want to have to remember a specific value for each time. For the most part, this tends to be “effects” like shadows and glows, border radius values, and sometimes spacing values as well.
Base styles
The styles that set the stage for the rest of the project. They tend to be very general and low specificity (often element selectors).
@layer base {
/* custom properties here */
body {
font-family: var(--ff-body);
font-size: var(--font-size-regular);
color: var(--text-main);
background-color: var(--background-main);
}
}
Meaningful links
Styling links as buttons is a common technique, but it sucks for a11y. The problem with buttons is that often want to keep the text inside of them short, and we often don’t have control over the copy that shows up on the screen. That doesn’t mean we can’t improve things for people using assistive technologies though, by using a .sr-only
(screen reader only) or .visually-hidden class
.
One neat approach, is having this in a utilities
layer, stacked as the highest priority layer.
@layer utilities {
.visually-hidden {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
<a href="#" class="button">
Learn more
<span class="visually-hidden">
about sorting your tax with our helpful reference guide
</span>
</a>
Big picture design system
Wrappers
As with just about any project you’ll ever work on, you need to limit the maximum width the content can reach.
@layer layout {
.wrapper {
max-width: calc(1130px + 2rem);
margin-inline: auto;
padding-inline: 1rem;
}
}
Modifiers
Option 1: BEM approach
Great a base wrapper. If your design has multiple sections that need to spread to differing widths, the modifier class pattern is a great choice. A modifier tweaks a subset of attributes of another class which it bases itself on, in this case would have the standard wrapper and then narrow and wide variants, like so:
@layer layout {
.wrapper {
max-width: calc(1130px + 2rem);
margin-inline: auto;
padding-inline: 1rem;
}
.wrapper--narrow {
max-width: 720px;
}
.wrapper--wide {
max-width: 1330px;
}
}
The --
is from the BEM naming convention, and helps distinguish that something is a modifier class, and not simply the name of something. Use like so:
<div class="wrapper">...</div>
<div class="wrapper wrapper--narrow">...</div>
<div class="wrapper wrapper--wide">...</div>
Option 2: Data attributes
Andy Bell’s CUBE CSS leaned into data-attributes as a neat alternative.
.wrapper {
--wrapper-max-width: 1130px;
--wrapper-padding: 1rem;
max-width: var(--wrapper-max-width);
margin-inline: auto;
padding-inline: var(--wrapper-padding);
}
.wrapper[data-type="narrow"] {
--wrapper-max-width: 720px;
}
.wrapper[data-type="wide"] {
--wrapper-max-width: 1330px;
}
Using them is quite clean:
<div class="card card--full-bleed card--inverted">...</div>
<!-- vs. -->
<div class="card" data-layout="full-bleed" data-theme="inverted">...</div>
Finally the pièce de résistance of this method is CSS nesting 🤌🏼
.wrapper {
max-width: calc(1130px + 2rem);
margin-inline: auto;
padding-inline: 1rem;
&[data-width="narrow"] {
max-width: 720px;
}
&[data-width="wide"] {
max-width: 1330px;
}
}
Finally, lets DRY things up with locally scoped custom properties.
@layer layout {
.wrapper {
/* locally scoped properties */
--wrapper-max-width: 1130px;
--wrapper-padding: 1rem;
max-width: calc(var(--wrapper-max-width) + 2rem);
margin-inline: auto;
padding-inline: var(--wrapper-padding);
&[data-width="narrow"] {
max-width: 720px;
}
&[data-width="wide"] {
max-width: 1330px;
}
}
}
Landmark regions
Landmark regions (<header>
, <nav>
, <main>
, <footer>
, <aside>
, <section>
and <form>
) serve as important navigational and structural elements on a webpage, helping users, esp assistive technologies, to understand and navigate the page’s organisation. They act like signposts on a webpage, dividing the content into meaningful sections that help screen readers jump to relevant parts, improve a11y, create logical structure and enable better keyboard nav.
It can be tempting to style landmark regions directly, but its often best to create classes (or use descendant selectors) to style them. This is because they can be used in many different ways. For example, a <header>
used for the top area of your page with your navigation in it, and then others inside of <article>
elements.
.site-header
.site-footer
.primary-navigation
.section
@layer layout {
.section {
padding-block: 3.75rem;
@media (min-width: 760px) {
padding-block: 8rem;
&[data-padding="compact"] {
padding-block: 4.5rem;
}
}
}
}
Gems
- Emmet is pre-installed with VSCode, to boilerplate a fresh HTML file
!
then tab - Media queries now support a clearer range syntax e.g.
@media (width > 760px)
:focus-visible
selector rocks, unlike:focus
, the browser will un-focus the element based on its type (e.g. a button that is clicked and un-hovered will un-focus automatically)