Modern CSS Without a CSS Library
BudgetFlow styles its pages without a CSS library. One stylesheet, base.css, holds the colors, the defaults of plain HTML elements and a handful of shared button classes. Every page and component styles itself in a small @scope block next to its markup.
Until recently, the element defaults came from Pico CSS, which we described in an earlier article. Pico's maintainers have since announced that version 2.1.1 is the final release. We ported the parts we use into our own file, and the pages look the same as before.
Most of what a CSS library did for us a few years ago, the browser now does with built-in features. This article shows the ones we use and how we made sure that no page changed.
Cascade Layers Decide Who Wins
All of base.css sits in one cascade layer. Styles outside any layer beat styles inside a layer, whatever their specificity. The @scope blocks of our pages are not in a layer, so a short selector in a page always overrides a default from base.css.
/* base.css */
@layer base {
[type="submit"] {
margin-bottom: var(--flow-space);
}
}
<section>
<style>
@scope {
[type="submit"] {
margin-bottom: 0;
}
}
</style>
<form>...</form>
</section>
The page never needs !important or a longer selector to win. A reader of the page sees its styles in the same file as its markup.
One Color Token for Both Themes
Every color in BudgetFlow is a custom property on :root. The function light-dark() holds the value for the light theme and the value for the dark theme in one place. The browser picks one of them from the color scheme of the element.
:root {
color-scheme: light dark;
--background-color: light-dark(#fff, oklch(20.4% 0.017 264));
--text-color: light-dark(oklch(35.5% 0.015 260), oklch(82.8% 0.014 262.4));
--border-color: light-dark(oklch(93% 0.012 260), oklch(30% 0.015 260));
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
With color-scheme: light dark, the page follows the system setting. The theme switch in the footer stores a choice in a cookie, and the server renders it as data-theme on the <html> element, so the first paint already has the right colors. A page that needs a new color writes one line with both values, and it works in both themes.
light-dark() only accepts colors. Our soft card shadow has two layers, so it calls light-dark() for the color of each layer and keeps the offsets and blur radii shared.
Buttons Built From Custom Properties
A button reads its color from one custom property, --button-color. The variants .secondary and .contrast only change it, and .outline draws it as border and text in place of the fill. color-mix() derives the hover color, darker in the light theme and lighter in the dark theme, and the half-transparent focus ring. Nesting keeps all of it in one block. A shortened version:
:is(button, [type="submit"], [type="button"]) {
--button-color: var(--primary-background-color);
--on-button-color: var(--on-primary-color);
--button-hover-color: color-mix(in oklab, var(--button-color), light-dark(#000, #fff) 15%);
border: 1px solid var(--button-color);
background-color: var(--button-color);
color: var(--on-button-color);
&:where(:hover, :active, :focus) {
border-color: var(--button-hover-color);
background-color: var(--button-hover-color);
}
&:focus-visible {
box-shadow: 0 0 0 2px color-mix(in oklab, var(--button-color) 50%, transparent);
}
&.outline {
--button-color: var(--primary-color);
background-color: transparent;
color: var(--button-color);
}
&.contrast {
--button-color: light-dark(oklch(22.65% 0.019 266.2), oklch(95.75% 0.0046 258.3));
--on-button-color: light-dark(#fff, #000);
}
}
The focus ring uses :focus-visible. A keyboard user sees the ring, and a button that someone clicked to open a modal does not keep a ring around it while the modal is open.
The same approach gives us compact forms. Fields and buttons read their inner spacing from --control-padding-block and --control-padding-inline, and the space below a field from --flow-space. The asset page of the wealth planner lowers these three properties on its container, and every field inside becomes compact without a single field selector.
@scope {
:scope {
--control-padding-block: 0.5rem;
--control-padding-inline: 0.75rem;
}
/* The grid spaces these fields with gaps */
form {
--flow-space: 0;
}
}
Selectors That Replace JavaScript and Classes
:has() lets an element react to what it contains. A label with a checkbox inside becomes as wide as its content and shows a pointer. While a modal is open, the page behind it stops scrolling, without a script that adds and removes a class.
label:has([type="checkbox"], [type="radio"]) {
width: fit-content;
cursor: pointer;
}
body:has(ui-modal) {
overflow: hidden;
}
None of these need a class in the template. The markup stays plain HTML with ARIA roles, and the stylesheet follows its structure.
How We Checked That Nothing Changed
A stylesheet for a whole app touches every page, and a missed rule shows up somewhere nobody looks. We compared the old and the new styles with three throwaway scripts.
-
A test script rendered 88 pages and modals through our test fixtures and saved their HTML, once with Pico and once with
base.css. - Playwright loaded each pair in five configurations: light, dark, a dark theme picked on a light system, a light theme picked on a dark system, and phone width. For every element, it compared about a hundred computed properties and the element's position and size.
-
For the buttons, fields and links of the main pages, the Chrome DevTools Protocol forced
:hover,:focus,:focus-visibleand:activewithCSS.forcePseudoState, and the script compared the styles in each state. - Full-page screenshots of every page, in light and dark, showed any difference the computed styles could not show.
Some differences in computed values do not change a single pixel. A transparent shadow and no shadow look the same, and so do color-scheme: light dark on a light system and color-scheme: light. The scripts rewrote these into one form before comparing, so the report only listed differences that matter.
What the Comparison Caught
The first run reported over a hundred thousand differences. Most of them were the harmless ones described above, and the rest came from a few mistakes in our first draft.
-
A nested rule ended in a combinator,
:where(p, ul) ~ { h2 { ... } }. That is not a valid selector, and the browser dropped the whole rule without a warning. Every heading that follows a paragraph lost the extra space above it. - Pico gives headings their own font. Our draft let them inherit the font of their container, so the headings of the landing page, which sets another font, changed.
-
Pico passes many values through inherited custom properties. Inputs inside a table header inherited the header's bold weight and a 3px border from it. We kept the bold labels with plain
font-weightinheritance and gave the inputs the same border as every other input.
We changed one thing on purpose. Pico gave table cells the page background, which showed as a darker band where a table sat on a panel in the dark theme, and one admin page had a rule to undo it. Cells are now transparent, and that rule is gone.
After the fixes, the screenshots of every page matched. The only differences left were the changes we chose and content that changes on its own, like timestamps.
The Result
| Stylesheet | Size | Gzipped |
|---|---|---|
| Pico CSS 2.1.1, minified | 83 KB | 11.7 KB |
base.css, with comments |
36 KB | 8.5 KB |
base.css is smaller even with its comments. It covers the HTML elements we write, and it leaves out the components that BudgetFlow builds as web components, like modals and popups, and the Pico components we never used, like dropdowns and tooltips. We also deleted the workarounds we had written against Pico's defaults, such as a reset of its font size at wide screens and a rule that hid its focus ring after a click.
We now maintain these styles ourselves. In return, every rule is in one file in our repository, written with features that every current browser supports, and changing a default means editing a rule, not overriding one from a library.
A CSS library still helps a new project look consistent on the first day. For us, the defaults fit into one readable file, and the browser features that make it short are the ones described above.