Prologue
A while ago, I decided to develop a fully accessible main navigation component in React and write a series of articles documenting the steps it took to create a non-trivial accessible component.
In the last article, the focus was on the operability of foundational components that render valid HTML elements. This article focuses on screen perceivability and the foundational aspects around theming, design and CSS.
CSS is used to illustrate design concepts. To that end, the theming system I'm using is solely composed of stylesheets.
Note: This article is one of a series demonstrating how to build a React navigational component from scratch while considering accessibility through the process. The articles are accompanied by a GitHub repository with releases tied to one or more articles; each building on the previous, until a fully implemented navigation component is complete.
Each release and its associated tag contain fully runnable code for the article. The code discussed in this article is available in the release. and may be downloaded at release 0.2.1. A page showcasing these base components may be run locally through this release.
While code examples are written in JavaScript for brevity, all actual code is written in Typescript and targets React 19.x. Examples use Next.js 16.x, which is not required to run the navigation component.
Follow along either by downloading the release and running the examples while examining the codebase, or by activating the link accompanying each code snippet to view the full file on GitHub.
Content Links
- Introduction
- Primitive Components
- Santitization Scripts
- CSS Cascade Layers
- CSS Nesting
- Colors
- Design Tokens for Theming
- Component Theming
- Component Styling
- Box-Sizing with Border-Box
- Summary
Introduction
In modern web development, theming systems are everywhere. Many of them integrate directly with JavaScript frameworks and support component styling by converting JavaScript objects into CSS with preprocessors like Styled Components or CSS-in-JS. Not surprisingly, I have thoughts around this.
A tightly integrated theming system tied directly to JavaScript, such as Material-UI or Radix-UI, along with the requirement to write CSS within component files, can cause issues when upgrading or even moving to a different framework.
This tight integration breaks the principle of separation of concerns. A theming system should sit alongside the project it affects and be available no matter the framework or language used. To my mind, a theming system should work just as well in a static HTML site as it does within a front-end framework such as Angular or React and work seamlessly when something in the stack needs to change.
Modern CSS has evolved in leaps and bounds over the last few years. Psuedo-selectors such as :has() and :not() make targeting parent elements based on their children's attributes, or lack thereof, a reality. It's worth revisiting if you haven't looked at it in a while.
I've had a love/hate relationship with CSS for a long time. I love the cascade aspect of CSS, but most styling frameworks seem to give it up in favor of writing styles directly in JSX or TSX files, with everything applied as classes. This approach adds complexity and reduces maintainability, especially when tracing style issues across components. I find it harder to maintain consistent styling when CSS can't be applied consistently across components. Is it doable? Yes. Is it easy and intuitive? No.
For most of my career, CSS has seemed like something I fight rather than something that just works. Dealing with browser inconsistencies and the battle between the styles set by third-party component libraries, the desires of the various design teams I've worked with, alongside the requirement to keep all styling in components and style everything in a class, has been my reality for years. Working with designs created by those who've rarely considered specificity or cross-browser styles has been challenging. Not to mention the issues of overriding a component library's styles to conform to another's vision.
My vision for a theming system is one built on modern CSS, which, for the most part, sits alongside my React components. I've built it on four pillars.
Primitive components: are those components where the focus by the developers has been on operability and functionality, not on perceivability. These components are released without any CSS styles and are styled only by the browser in which it is run.
Sanitization scripts: to standardize styles across browsers and, in many cases, removing most of the visual styling, goes a long way toward reducing the headache of trying to make something look the same way across browsers.
CSS Cascade Layers: widely supported across browsers, allows for defining explicit contained layers of specificity, reducing the need for long strings of classes and the use of !important.
CSS Nesting: allows for writing more modular, maintainable and readable CSS. It's similar to SASS nesting, using the same & operator, but it also supports all the newer pseudo-element selectors, which sadly SASS hasn't kept up with.
Primitive Components
Open source primitive component libraries abound. Radix, React Aria Components, Reach, Reakit and AriaKit all provide unstyled components that emphasize operability and accessibility. There's no fighting over opinionated CSS because there is no opinion on how to style these components other than those of the people responsible for the design.
My current implementation uses react-aria-components, an underlying primitives library provided by Adobe.
Primitive libraries, which feature operable components with no styling applied, have reduced the need to reconcile component-agnostic styling with corporate directives. I will admit I was intimidated by the idea of adding all the styling necessary to components, but the payoff has been worth it.
Santitization Scripts
A good set of sanitization scripts goes a long way towards sane styling. Sanitization scripts remove the inconsistencies across browsers, and the one I use removes most visual styling altogether.
Developing a vanilla theming system using modern CSS, along with robust sanitization scripts and primitive components that are initially operable but not perceivable, has changed the way I think about the HTML elements I work with every day. The first time I loaded a sanitized, unstyled, empty button onto a page, I thought I did something wrong. My screen was blank; the only way I knew a button was loaded was by checking the DOM. I added a border width and style, still nothing. It was only when I applied a color to the border that a small box appeared. I added text to my button, and to my amazement, it was flexible: it had no specified height or width, and no padding or margins; it hugged its content tightly.
Standardizing on box-size: border-box made a huge difference. Suddenly, setting a specific width meant the element's width never changed. Border widths and padding sizes are part of the set width. True consistency is achievable.
CSS Cascade Layers
CSS Layers applies an order of precedence based on an @rule, reducing the need for specificity. Styles lower in the layer hierarchy are easily overridden by styles in higher layers, reducing the need for !important declarations and long, hard-to-follow selectors.
@layer reset, system, default-theme, base-component, common-component, system-component, main;
GitHub (release 0.2.1) - Theming System - layers.css
Layers within a theming system are self-defined. We determine which layers exist and which layers are higher in precedence. Layers are applied from left to right; any overrides in a layer defined to the right are applied without additional specificity or the need for !important.
In my layer system, I begin with reset, which contains my sanitization scripts that remove all padding and borders and standardize elements across browsers.
Anything defined in the reset layer may be overridden by the other layers, including the system layer, which holds design tokens for colors and underlying choices, such as the creation of my relative pixel base size and global srOnly class.
@layer system {
:root {
--sizing-base: 1rem;
--sp-px: calc(var(--sizing-base) * 0.0625);
--shadow-opacity: 0.3;
--overlay-opacity: 0.2;
}
.srOnly {
border: 0;
clip: rect(1px, 1px, 1px, 1px); /* 1 */
clip-path: inset(50%); /* 2 */
height: 1px;
margin: -1px;
overflow: hidden !important;
padding: 0;
position: absolute !important;
width: 1px;
white-space: nowrap !important; /* 3 */
}
}
GitHub (release 0.2.1) - Theming System - base.css
While layers reduce the need for the keyword, !important, it still has its uses. In the case of my srOnly class, the properties overflow, position and whitespace may not be overwritten no matter what. Since those three properties define removing the class applied element from the screen, they are integral to the style.
Layers address many of the issues around specificity and are well worth the time to get to know them. They form an integral part of my theming system.
CSS Nesting
Nested CSS, a fully supported feature of CSS, mimics SASS in some ways, and when implemented inside @layer, removes the need for chaining selectors and allows for more modularity. When base components render into actual structured HTML elements, the need for adding a class to everything disappears.
I think the mindset around CSS usage has suffered from the overuse of classes, and I blame Macromedia for their decision when creating Dreamweaver to make everything a class, which in turn has influenced a generation of developers. Trying to debug conflicting styles when everything is styled via a string of classes to provide enough specificity has turned many stylesheets into a morass where developers fear to tread.
When nested CSS is used alongside HTML elements rather than classes, everything starts to make sense.
@layer main {
#base-components {
& > ul.general {
align-items: flex-start;
column-gap: calc(var(--sp-px) * 16);
display: flex;
flex-direction: row;
list-style: inside;
width: 100%;
& li {
border-width: var(--component-border-width);
border-style: solid;
border-color: transparent transparent transparent var(--purple-3);
list-style: none;
padding-left: calc(var(--sp-px) * 16);
padding-bottom: calc(var(--sp-px) * 16);
width: 30%;
&:first-child {
border-left-color: transparent;
}
& a {
text-decoration: underline;
}
}
}
& > ul:not(.general) {
display: flex;
gap: calc(var(--sp-px) * 16);
position: relative;
flex-wrap: wrap;
list-style: none;
& > li {
padding: 0 calc(var(--sp-px) * 16);
width: 47%;
}
& > li div {
border-width: calc(var(--sp-px) * 8);
padding: calc(var(--sp-px) * 32);
mask: radial-gradient(
calc(var(--sp-px) * 21) at 50% calc(var(--sp-px) * 21),
#0000 calc(100% - var(--sp-px) * 1),
#000) 50% calc(-1 * var(--sp-px) * 21) / calc(var(--sp-px) * 38.85) 100%;
}
}
}
}
GitHub (release 0.2.1) - baseComponentsPage.css
The example above is a snippet styling the base components example page in the demo. Notice how everything cascades from a single id, using descendant selectors to drill down into elements and apply styling. All widths and gaps are either percentages or derived as relative pixels using the technique I described in a previous article, Spacing Considerations In Acessible Design.
With these four pillars in place, everything I dreaded about working with CSS disappeared. Instead of fighting CSS specificity and trying to debug why a particular style was being overridden and from where within a sea of CSS within components, something changed. CSS flowed, it was easy and best of all, it just worked.
Colors
Perceivable colors that automatically work in both light and dark modes, and that provide sufficient color contrast between foreground and background, are not hard to achieve, especially when each color is separated into a set of colors sharing the same hue, varying only saturation and lightness.
Using human-readable color spaces such as LCH or HSL is preferable to hex codes or RGB because colors can be computed from a given hue. Both are human-readable and fully supported across browsers. I've used both in this theming system across two colors: gray uses HSL, and purple uses LCH. I'm starting to prefer HSL for the many generators available and its ease of readability, which is easier to understand than LCH.
Colors are stored in color files, each of which can define 7 or 12 design tokens that differ mostly in saturation and lightness. Definitions in the color files can be set to automatically change based on a user's light or dark mode preference.
@layer system {
/*Based off hue 151 */
:root {
/*Light Desaturated - Background */
--raw-purple-1: 96.73% 0.00508 325.62;
--raw-purple-2: 90.813% 0.02278 321.35;
--raw-purple-3: 84.919% 0.03759 320.35;
/* Light Saturated */
--raw-purple-4: 79.126% 0.0733 320.93;
--raw-purple-5: 73.236% 0.0934 321.48;
--raw-purple-6: 67.281% 0.10972 321.13;
/* Medium */
--raw-purple-7: 62.241% 0.10499 321.38;
--raw-purple-8: 55.082% 0.09248 321.86;
--raw-purple-9: 48.822% 0.08156 321.03;
/* Dark */
--raw-purple-10: 42.402% 0.07362 322.08;
--raw-purple-11: 36.178% 0.06256 322.17;
--raw-purple-12: 29.673% 0.05099 322.32;
--purple-1: oklch(var(--raw-purple-1));
--purple-2: oklch(var(--raw-purple-2));
--purple-3: oklch(var(--raw-purple-3));
--purple-4: oklch(var(--raw-purple-4));
--purple-5: oklch(var(--raw-purple-5));
--purple-6: oklch(var(--raw-purple-6));
--purple-7: oklch(var(--raw-purple-7));
--purple-8: oklch(var(--raw-purple-8));
--purple-9: oklch(var(--raw-purple-9));
--purple-10: oklch(var(--raw-purple-10));
--purple-11: oklch(var(--raw-purple-11));
--purple-12: oklch(var(--raw-purple-12));
}
@media (prefers-color-scheme: dark) {
:root {
/*Light Desaturated - Background */
--raw-purple-12: 96.73% 0.00508 325.62;
--raw-purple-11: 90.813% 0.02278 321.35;
--raw-purple-10: 84.919% 0.03759 320.35;
/* Light Saturated */
--raw-purple-9: 79.126% 0.0733 320.93;
--raw-purple-8: 73.236% 0.0934 321.48;
--raw-purple-7: 67.281% 0.10972 321.13;
/* Medium */
--raw-purple-6: 62.241% 0.10499 321.38;
--raw-purple-5: 55.082% 0.09248 321.86;
--raw-purple-4: 48.822% 0.08156 321.03;
/* Dark */
--raw-purple-3: 42.402% 0.07362 322.08;
--raw-purple-2: 36.178% 0.06256 322.17;
--raw-purple-1: 29.673% 0.05099 322.32;
}
}
}
GitHub (release 0.2.1) - Theming System - purple.css
The color file for purple, shown above, defines the same colors in both light and dark color schemes as the raw variables, which are then defined using oklch(). This enables an automatic dark color scheme and allows using the --raw variables when opacity needs to be adjusted. My initial foray into this was discovering the custom palettes available through Radix-UI, though I don't use their generator at all.
A color sheet created in this manner also allows for easily achievable color contrast. Formulas for non-vibrant colors achieve color contrast with variables -1 through -4 when paired with any color in the -8 through -12 range. You can see this in a palette color theming system called primer prism.
Design Tokens for Theming
CSS variables are not just for setting colors. They are flexible and mutable, allowing themselves to be set and reset to affect only those elements contained in a selector. Design tokens can be used by base components, tying them into the design system in a way that goes far beyond simple color selection.
<div id="initial">
<div>
<div>
<div></div>
</div>
</div>
</div>
<div id="secondary">
<div>
<div>
<button>Hello World</button>
</div>
</div>
</div>
Consider the HTML code above. Two series of nested empty divs, with the second series also holding a button in the innermost div.
#initial {
--theme-background: var(--red-1);
width: calc(var(--sp-px) * 160);
& div {
background-color: var(--theme-background);
padding: calc(var(--sp-px) * 20);
}
> div {
border: 1px solid var(--theme-border-color);
& > div {
--theme-background: var(--purple-2);
& > div {
--theme-background: var(--gray-3);
}
}
}
}
The CSS sets the —theme-background token to a light red and applies it to every div in the DOM, along with padding. Using Nested CSS and the descendant selector, I can target the individual divs nested within each other and change the theme background.
#secondary {
--theme-background: var(--gray-1);
position: relative;
left: 25%;
top: calc(var(--sp-px) * -160);
margin-top: calc(var(--sp-px) * 20);
width: calc(var(--sp-px) * 180);
& div {
background-color: var(--theme-background);
padding: calc(var(--sp-px) * 20);
}
& > div {
--theme-background: var(--gray-3);
& > div {
--theme-background: var(--red-3);
& button {
--button-background: var(--purple-6);
--button-color: var(--gray-1);
padding: calc(var(--sp-px) * 20);
}
}
}
}
In the secondary example, a button is targeted by a —button-background token, along with setting the text color to a lighter value.
Variables set at a specific point in the DOM affect only the items contained within it.
Component Theming
The theming I'm using in the repository is pared down to the bare minimum, and isn't the best representation of what it will eventually become. But it's sufficient to demonstrate the power of plain CSS and Structural HTML.
/* Button*/
button {
/* Common widths and spacing */
--button-padding: calc(var(--sp-px) * 4) calc(var(--sp-px) * 12);
--button-border-radius: calc(var(--sp-px) * 8);
--button-border-width: calc(var(--sp-px) * 2);
--button-font-size: inherit;
--button-font-weight: 500;
--button-line-height: 1.4;
--button-letter-spacing: normal;
/* theme - Solid */
--button-background: var(--purple-2);
--button-background-disabled: var(--gray-3);
--button-background-interactive: var(--purple-3);
--button-background-pressed: var(--purple-4);
--button-border-color: var(--gray-5);
--button-border-color-disabled: var(--gray-3);
--button-border-color-interactive: var(--gray-9);
--button-border-color-pressed: var(--button-border-color);
--button-color: var(--gray-12);
--button-color-disabled: var(--gray-10);
--button-color-interactive: var(--purple-11);
--button-color-pressed: var(--gray-11);
--button-fill: var(--purple-12);
--button-fill-disabled: var(--button-color-disabled);
--button-fill-interactive: var(--button-color-interactive);
--button-fill-pressed: var(--button-color-pressed);
/* Ghost */
--button-ghost-background: transparent;
--button-ghost-border: transparent;
}
GitHub (release 0.2.1) - component.css
I'm going to use the button component to demonstrate the theming. You'll notice that design tokens have been set up not only for background, border and text colors, but also for states. There are tokens to hold colors for interactive states, including :hover and :focus, and the pressed variables hold changes when the :active state is in place. Because buttons can hold icons, tokens for various fill states are also set.
With the base button tokens defined, it's time to add them to the actual component.
Component Styling
Component stylesheets live in the same folder as their component, and, since I'm using Next.js, are side-loaded into the globals.css file to ensure they are loaded only once. Not every base component needs to include specific styling; where styling is present, it should be generic. Styling for base components is applied in an @layer base-component{element{}} to ensure initial styling is available regardless of where the HTML element is located.
GitHub (release 0.2.1) - button.css
button {
/* Layout */
align-content: center;
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
margin: 0;
padding: var(--button-padding);
width: fit-content;
}
Every button starts with a flex layout that centers its content and initially sets its width to fit the content. Padding is wired in through the button-padding token since that can and will be changed regularly.
button { /* continued */
/* Text */
font-size: var(--button-font-size);
font-weight: var(--button-font-weight);
letter-spacing: var(--button-letter-spacing);
line-height: var(--button-line-height);
text-decoration: none;
/* Appearance */
border-radius: var(--button-border-radius);
border-style: solid;
border-width: var(--button-border-width);
cursor: pointer;
}
Text and appearance depend on more tokens, which can be overridden wherever necessary.
button { /* continued */
/* Colors */
--svg-color: var(--button-color);
--svg-fill: var(--button-fill);
background-color: var(--button-background);
border-color: var(--button-border-color);
color: var(--button-color); }
All colors associated with the button are set to the tokens by default. Either the background-color or the token may be overridden.
button { /* continued */
/* States - */
&[aria-disabled="true"] {
--button-background: var(--button-background-disabled);
--button-border-color: var(--button-border-color-disabled);
--button-color: var(--button-color-disabled);
--svg-color: var(--button-color-disabled);
--svg-fill: var(--button-fill-disabled);
cursor: default;
}
&:focus-visible {
outline: var(--focus-outline) var(--focus-outline-color);
outline-offset: var(--focus-outline-offset);
}
&:focus,
:hover {
--button-background: var(--button-background-interactive);
--button-border-color: var(--button-border-color-interactive);
--button-color: var(--button-color-interactive);
--svg-color: var(--button-color-interactive);
--svg-fill: var(--button-fill-interactive);
}
}
When it comes to state changes, I prefer to style them using either aria- attributes or pseudo-classes such as :focus and :hover. Styling on aria- attributes reinforces accessibility. If the aria- attribute is required, why not style it through the attribute rather than adding in yet another class that needs to be deciphered?
In the article Foundational Accessibility Begins with the Base Components, I explained how the disabled attribute causes issues with screen readers and proposed a fix that used aria-disabled instead. The styles around the aria-disabled attribute are the last aspect necessary. Note how every line in the rule simply replaces the original token with one based on a disabled state. That's all that is necessary. It's the same with the focus and hover pseudo-classes.
Box-Sizing with Border-Box
My sanitization scripts apply the box-sizing: border-box rule to every element. This box-sizing rule defines the width and height to include content, padding, and borders. I've found this rule makes it easier to work with positioning and layout, since a component's width and height remain constant regardless of padding or borders.
When using "border-box", note that changing from "border: none" to "border: solid" or adjusting the border-width during a state change will shift the component's content. My solution is to set the border width in advance and use "color: transparent" to emulate a no-border effect. In this scenario, a user sees a borderless box since the transparent border sits on top of the background. During a state change, there is no shifting, since the border simply swaps colors. Make sure the border-width remains consistent.
Summary
Adding a theming system doesn't have to be complicated or require another package. Regardless of the theming system you use, wiring the components that render to HTML into your system will save a lot of time and headaches down the road.
With the base components added in and connected to the theming systems, the next step will be to create the actual components of the Navigation system.



Top comments (0)