Building a CSS only design system with custom properties
Simplifying design system theming
After years of wrestling with theming solutions - SASS variables, CSS-in-JS, theme providers, utility frameworks - I've reached a conclusion:
We've drastically overcomplicated theming in modern web development.
Last week at RNL Conf, this hit home during a design systems discussion. The room had the classic framework talk. Tailwind for utilities. Material UI for Google's design language. Chakra for component-driven development. Styled Components for CSS-in-JS.
Each with the promise to solve all our theming problems. Each in my mind adds its own complexity.
An attendee asked about my experience. I shared our approach at my mid-sized company: CSS Modules for scoping. That's it.
The simplicity of this was in stark contrast to our previous discussion, but honestly I didn't and still don't know why all these tools are needed. Yes, we have developed an over-reliance on frameworks to handle what should be straightforward, and I say this as someone who adores Next.js.
But let's think about it for a second.
Our complex solutions emerged from a different era of web development:
- SASS variables filled the gap before CSS had dynamic values
- CSS-in-JS solved component-level styling
- Utility classes sped up development
- Theme providers added type safety and IDE support
Each solution solved problems of its time. But here's the key: the web platform has evolved. Our approaches should too.
Many of these tools predate the true power of custom properties and modern CSS features.
Let me show you something different: We can build a sophisticated theming system using just CSS custom properties.
No preprocessors. No JavaScript engines. No utility frameworks. Just modern CSS features that work in every major browser.
The approach is simple: define your base colour palettes as custom properties and use inheritance for theme switching.
Plan your variables thoughtfully. Build relationships carefully. The result? A clean, scalable system that grows with your application - no external dependencies needed.
First define your root styles✨
Let's start with the foundation of our theming system by setting up the root styles. This is where we'll define our colour palettes and establish the inheritance patterns. Here's a look at our abridged base CSS file:
With something like this we are conceptually 90% of the way there, which is scary because most of us are used to really complex solutions to handle theming (Providers
I'm looking at you).
Let's break down what's happening:
- First, we define our base color palettes using CSS custom properties. Each color gets its own variable with a descriptive name following a consistent numbering system (50 for lightest, 900 for darkest).
- Instead of applying these colors directly, we create an abstraction layer using
data-theme
attributes. This is the magic that makes our theming so flexible. - The
data-theme
selectors map our base palette variables to semantic theme variables (--primary-50
,--primary-500
, etc.). This simple layer of abstraction means we can swap entire themes by changing a single attribute. And this works at any level in our DOM tree.
Using it in your elements
Here's where it gets really nice. Your css just references the primary colors, and your elements will automatically adapt when you switch themes:
This setup gives us incredible flexibility:
- By default, elements inherit their theme from the nearest parent with a
data-theme
attribute - You can override the theme at any level in your DOM tree
- Components remain largely theme-agnostic, using only the semantic colour variables
- Switching themes is as simple as changing a
data-theme
attribute
Demo
Here's an interactive demo of something similar in action. Click the square blocks or items to change their theme. You can see how the theme can be taken to the extreme granular level
Click on a block to change its theme
Why I like this approach
I've worked with a lot of different theming solutions over the years, and here's why I keep coming back to this pattern:
- It's Simple: The inheritance chain is short and easy to follow. You can look at the CSS and understand exactly what's happening.
- It's Flexible: Need to add a new color scheme? Just define your colors and add a new theme rule. No build step, no configuration files.
- It's Fast: The browser handles all the color inheritance natively. No JavaScript calculations, no class swapping.
- It's DRY: Your components reference primary colors once and automatically adapt to theme changes. No repeated color classes.
- It's DevTools Friendly: You can inspect and modify colors right in your browser and you know where they come from. Try doing that with preprocessor variables or utility classes 🫠.
Change themes with minimal JavaScript
If you do need to switch themes, here's all the JavaScript required:
Extending to light and dark mode
The same principle works beautifully for light and dark themes. Here's how we can expand our system:
Just like with our colour schemes, components don't need to know about light or dark mode - they just reference the semantic variables.
The JavaScript remains just as simple:
Beyond colors: a complete design System
While this CSS-only approach to theming is powerful, it's important to note that a complete design system needs much more than just colour variables. A robust design system typically includes at least the below
Other important considerations for a complete design system include:
- Animation timings and easing
- Border radii and shadow scales
- Z-index scale
- Component-specific attributes
The power of composition
The real strength of using CSS custom properties comes when you combine all these systems. Here's an example of a card component that uses multiple token types:
While CSS custom properties make theming straightforward, a production-ready design system needs careful planning of all these variables and their relationships. The goal is to create a consistent, maintainable system that scales with your application's needs.
The future of theming is already here
The next time you reach for a complex theming solution, ask yourself: Do you really need it? Or could you achieve the same result with the powerful features already built into the web platform?