A System, Not a Stylesheet#
Early in the frontend work it became clear that ad-hoc styling would not hold up across a growing component tree. The approach I settled on separates concerns at two levels: global files for things that belong to the whole application, and CSS Modules for everything scoped to a component.
The global layer is four files, imported in a specific order:
@import './styles/reset.css';
@import './styles/variables.css';
@import './styles/typography.css';
@import './styles/layout.css';Order matters here. The reset clears browser defaults first. Variables are defined next, so typography and layout can consume them without forward-reference issues. Nothing in the globals is component-specific — the moment a style belongs to one component, it moves into a module.
The boundary between global and scoped styles is not enforced by tooling — it is a convention, and there are a handful of places in the codebase where a value was hardcoded that should have been a token.
Icons and Illustrations#
Rather than pulling in an icon library, I drew the icons I needed in Figma and exported them as SVGs. vite-plugin-svgr lets them be imported as React components, which means they can receive props and respond to the current theme like any other component.
The theme toggle uses a custom sun and moon icon. The profile picture placeholder is a character silhouette with a D20 as the head — small detail, but it fits the context of the application.
Designing and working with .svg’s might not have a huge impact on the codebase, but I find it very interesting.
Design Tokens#
All raw values live in variables.css as custom properties. Spacing, color, radius, z-index, transitions, and font sizes are all named and referenced by token throughout the codebase — almost, I am only human after all.
The dark mode override sits in the same file, under :root[data-theme="dark"]. Only the tokens that change between themes are redeclared — backgrounds, text colors, borders, and a few component-specific values. Everything else inherits from the light theme by default.
The transition tokens deserve a mention. A single rule in layout.css applies --transition-base to background-color, color, border-color, and text-decoration-color on every element. The result is that the entire application transitions smoothly when the theme changes, without any component needing to handle it individually.
Dark Mode in State#
Most resources on dark mode in React suggest storing the preference in localStorage so it persists across sessions. I chose not to do that.
The preference is initialised from the browser’s own setting:
const [isDark, setIsDark] = useState(
window.matchMedia('(prefers-color-scheme: dark)').matches
);From there it lives in state in App, and the data-theme attribute is applied as a side effect:
useEffect(() => {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
}, [isDark]);toggleTheme is passed down to Header as a prop, which renders the toggle control. Header does not own the state — it receives the current value and a function to change it. This is lifting state deliberately applied: the toggle lives where the user sees it, but the truth lives where it can affect the whole document.
The decision to use state over localStorage was intentional. Persistence would have been straightforward to add, but the goal here was to practice the lifting state pattern in a context where it actually made sense — not to reach for the most complete solution immediately.
Reflections#
CSS Modules and a token-based global layer work well together, but they require discipline to maintain. The boundary between global and scoped styles is not enforced by tooling — it is a convention, and conventions only hold if they are followed consistently. Looking back, there are a handful of places where a value was hardcoded that should have been a token. They are small, but they are the kind of thing that compounds over time.
