惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

月光博客
月光博客
SecWiki News
SecWiki News
爱范儿
爱范儿
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Apple Machine Learning Research
Apple Machine Learning Research
Vercel News
Vercel News
S
SegmentFault 最新的问题
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
GbyAI
GbyAI
V
V2EX
博客园 - 司徒正美
WordPress大学
WordPress大学
Y
Y Combinator Blog
B
Blog RSS Feed
H
Help Net Security
C
Check Point Blog
P
Proofpoint News Feed
Google DeepMind News
Google DeepMind News
Application and Cybersecurity Blog
Application and Cybersecurity Blog
B
Blog
Help Net Security
Help Net Security
罗磊的独立博客
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
H
Heimdal Security Blog
大猫的无限游戏
大猫的无限游戏
Security Latest
Security Latest
Cisco Talos Blog
Cisco Talos Blog
Blog — PlanetScale
Blog — PlanetScale
A
Arctic Wolf
T
The Blog of Author Tim Ferriss
P
Proofpoint News Feed
The Register - Security
The Register - Security
F
Fortinet All Blogs
S
Securelist
Microsoft Security Blog
Microsoft Security Blog
O
OpenAI News
P
Privacy & Cybersecurity Law Blog
C
Cybersecurity and Infrastructure Security Agency CISA
The GitHub Blog
The GitHub Blog
云风的 BLOG
云风的 BLOG
AWS News Blog
AWS News Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
I
InfoQ
T
Threat Research - Cisco Blogs
Martin Fowler
Martin Fowler
D
Docker
C
Cisco Blogs
C
CERT Recently Published Vulnerability Notes
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Theming in the Modern Age
ShaynaProduc · 2026-05-14 · via DEV Community

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.0. 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

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;

Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.2.0) - 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 */
  }
}

Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.2.0) - 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%;
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.2.0) - 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;
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.2.0) - 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.

Primer Prism screen shot implementing the purple colors detailed earlier

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>

Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

A rendered example of the html and css code showing different colored backgrounds achieved solely by resetting the theme-background design token

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);
      }
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

A rendered example showing both the initial and secondary styling with the secondary div showing a gray background, superseded by a brighter red background and finally a button whose background becomes a deep purple with white lettering spelling out

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;
}

Enter fullscreen mode Exit fullscreen mode

GitHub (release 0.2.0) - 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.0) - 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;
  }

Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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);  }

Enter fullscreen mode Exit fullscreen mode

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);
    }
  }

Enter fullscreen mode Exit fullscreen mode

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.