Styling Approaches in React: Choosing the Right Tool
Survey the real options for styling a React app — plain CSS, CSS Modules, Sass, CSS-in-JS, utility CSS, and component libraries — understand design tokens and CSS variables as the foundation of theming, and learn why EngiLipi should extend its own existing theme architecture rather than introduce a second styling system.
There is no single correct way to style a React app — plain CSS, CSS Modules, Sass, CSS-in-JS, and utility-first frameworks are all used in production, each with real tradeoffs. What matters more than picking the 'best' one in the abstract is picking one system, understanding what it costs to mix approaches, and — on an existing codebase like EngiLipi — extending the theming architecture that's already there instead of layering a second, competing one on top of it.
The styling landscape: what each approach actually gives you
Plain CSS (one or more .css files imported into your app) is the simplest possible setup — no build step beyond what a bundler already does, no new syntax to learn, and it works everywhere. Its real cost shows up at scale: class names are global by default, so two components can silently collide on a class name like .card or .title unless the team enforces a naming convention (like BEM) by hand.
CSS Modules (Button.module.css, imported as an object of locally-scoped class names) solves that exact collision problem: the build tool rewrites .button to something unique like Button_button__a1b2c, so styles never leak between components, while the authoring experience is still just CSS. This is the approach EngiLipi already uses in places and is the natural default for new component-scoped styles going forward.
Sass (.scss) adds nesting, variables, mixins, and partials on top of plain CSS syntax, compiled down to regular CSS at build time — useful for reducing repetition in large stylesheets, but largely superseded for the 'variables' use case by native CSS custom properties, which don't need a compile step and can be read/changed at runtime (which matters directly for theming — see below).
CSS-in-JS libraries (styled-components, Emotion) let you write CSS inside .tsx files as tagged template literals or objects, co-located with the component and often supporting dynamic styles driven directly by props. The tradeoff is a runtime cost (style injection happens in the browser, sometimes with a flash-of-unstyled-content risk) and a new dependency and mental model layered onto every component — worth knowing about, but not something to introduce into a codebase that already has a working CSS Modules + CSS variables system.
Utility-first CSS (Tailwind being the dominant example) styles elements by composing many small single-purpose classes directly in JSX (className='flex items-center gap-2 text-sm') rather than writing separate stylesheet rules. It trades stylesheet files for longer className strings, and works well for teams that adopt it consistently project-wide from the start — but mixing it into a codebase already built on CSS Modules and semantic class names creates two competing styling vocabularies to maintain.
Component libraries (MUI, Chakra, Radix + your own styles, shadcn/ui) ship pre-built, often accessible-by-default components with their own styling approach baked in. They save real time on common widgets (date pickers, comboboxes, accessible dialogs) but come with a visual identity and bundle-size cost, and adopting one wholesale on top of an app with its own established design tokens usually means fighting the library's defaults as much as it means saving effort.
CSS variables and design tokens are the real foundation
A design token is a named, reusable design decision — a specific color, spacing value, font size, or border radius — given one canonical name instead of being repeated as a literal value across dozens of files. In CSS, native custom properties (--color-primary, --space-md, --radius-sm) are how tokens are implemented: defined once, typically on :root, and referenced everywhere else with var(--token-name).
The payoff over hardcoding literal values everywhere is twofold: consistency (every button uses the exact same blue because there's only one definition of it to reference) and runtime theming (changing what --color-primary resolves to instantly changes every element that references it, with no rebuild and no per-component code changes needed) — which is precisely the mechanism that makes light/dark/system theming possible without duplicating every stylesheet.
Design tokens are usually organized in two layers: primitive tokens (raw values like --blue-500: #2f6fed) and semantic tokens that reference them by role (--color-primary: var(--blue-500), --color-border: var(--gray-300)). Components should always reference semantic tokens, never primitives directly — that indirection is what lets --color-primary point at a different actual color per theme while every component that uses var(--color-primary) keeps working unchanged.
Dark mode, reduced motion, and high contrast — reuse EngiLipi's existing theme architecture
EngiLipi already has a working, real theme system, and any new React component's styling should plug into it rather than reinvent it. The existing architecture is: a ThemeProvider that owns theme state as ThemeMode ('light' | 'dark' | 'system'), persists the user's choice to localStorage, resolves 'system' via window.matchMedia('(prefers-color-scheme: dark)'), and applies the result as a data-theme attribute (plus color-scheme) on the root <html> element; a useTheme() hook exposing { themeMode, resolvedTheme, setThemeMode } to any component that needs to read or change it; and a theme.css file defining semantic tokens like --color-bg-primary, --color-surface, --color-text-primary/secondary/muted, --color-border, and --color-primary once for the dark data-theme value and once for the light one.
That means a new component almost never needs to touch useTheme() directly, and never needs its own theming logic — it just writes ordinary CSS Module rules referencing var(--color-*) tokens (background: var(--color-surface); color: var(--color-text-primary);), and it will automatically render correctly in light mode, dark mode, and system mode, because the token values underneath it change, not the component's code. Introducing a second theming mechanism (a separate CSS-in-JS theme object, a competing token set with different names) for a new feature would create exactly two systems to keep in sync and is the wrong move on an app that already solved this.
The same var(--color-*) tokens are the right foundation for reduced-motion and high-contrast support too: wrap transition/animation declarations in @media (prefers-reduced-motion: no-preference) so users who've opted out get instant state changes instead, and where a design needs a sharper border in high-contrast contexts, prefer expressing it as a token override (a --color-border value bumped up in specificity/opacity) inside an @media (prefers-contrast: more) block rather than a one-off hardcoded color that bypasses the token system.
One real bug pattern worth knowing before it happens again: a color hardcoded as a literal (not a var()) against a background that is tokenized will silently break the moment that background's resolved value changes with the theme — this exact class of bug (near-white text hardcoded against what became a light-mode background) has already been found and fixed in EngiLipi's own codebase, which is precisely why every new stylesheet should audit for literal colors before shipping, not just missing tokens.
Responsive design, briefly
Responsive layout (fluid grids, flexible images, and @media breakpoints that adapt layout to viewport width) is its own deep topic covered in full in a later module — but it's worth noting here that it composes cleanly with a token-based system: spacing tokens (--space-sm, --space-md, --space-lg) and a small set of shared breakpoint values keep responsive rules consistent across components the same way color tokens keep colors consistent, instead of every component picking its own ad hoc pixel breakpoints.
A component styled with CSS Modules, using EngiLipi's existing tokens
Every color comes from an existing semantic token (--color-surface, --color-text-primary, --color-border) — nothing is hardcoded, so the card automatically themes correctly in light, dark, and system mode with zero component-level theme logic. The reduced-motion media query strips the hover transition for users who've asked for less motion, without needing any JavaScript. CSS Modules scope .card, .title, and .meta locally, so this file can't collide with another component's .card class elsewhere in the app.
/* LessonCard.module.css */
.card {
background: var(--color-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-md);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
.title {
color: var(--color-text-primary);
font-weight: 600;
}
.meta {
color: var(--color-text-secondary);
font-size: 0.875rem;
}Code Examples
The imported styles object maps .card/.title/.meta to their build-generated unique class names. No component-level theme code is needed at all — switching the app's ThemeMode between light, dark, and system re-resolves the CSS variables this stylesheet references, and the card updates automatically. Expected result: A themed, hover-responsive lesson card that respects prefers-reduced-motion and matches the current site theme with no per-component theming code.
import styles from "./LessonCard.module.css";
import styles from "./LessonCard.module.css";
interface LessonCardProps {
title: string;
minutes: number;
onOpen: () => void;
}
function LessonCard({ title, minutes, onOpen }: LessonCardProps) {
return (
<button className={styles.card} onClick={onOpen}>
<span className={styles.title}>{title}</span>
<span className={styles.meta}>{minutes} min</span>
</button>
);
}How one token change flows through every component
One token definition in theme.css, referenced with var() everywhere — flipping the resolved theme changes every consumer at once, with no per-component code.
ThemeProvider (data-theme="dark" on <html>)
│
▼
theme.css --color-surface: #10151f (dark)
--color-surface: #ffffff (light)
│
├──► LessonCard.module.css background: var(--color-surface)
├──► NavBar.module.css background: var(--color-surface)
└──► SettingsPanel.module.css background: var(--color-surface)Important Rules
Prefer CSS Modules for new component styles on EngiLipi — locally scoped class names, no new dependency, and it's the pattern already in use.
Never hardcode a color, spacing, or radius literal in a new stylesheet when an existing semantic token (var(--color-*), var(--space-*), var(--radius-*)) already expresses it.
Reuse EngiLipi's existing ThemeProvider/useTheme/data-theme architecture for anything theme-related — do not introduce a second theming mechanism (a separate CSS-in-JS theme object, a competing token set) alongside it.
Always check a new text color against its actual rendered background in both light and dark mode, not just in isolation — literal colors on tokenized backgrounds are the most common theming bug.
Scope motion-heavy transitions inside @media (prefers-reduced-motion: no-preference) so they're skipped automatically for users who've opted out.
Why this matters for EngiLipi specifically
EngiLipi already tokenized its core app shell and every language course page onto the ThemeProvider/theme.css system, including catching and fixing real regressions (a frosted-glass sidebar left as a hardcoded dark literal, several components' text hardcoded to a near-white literal that broke in light mode). Every new React lesson or feature page should extend that same system from day one — writing CSS Modules against the existing var(--color-*) tokens — rather than risk reintroducing the exact class of bug that was already found and fixed once.
// LessonCard.module.css.card { background: #10151f; color: #e8edf5; }.card { background: var(--color-surface); color: var(--color-text-primary); }
A hardcoded literal color is correct in exactly one theme — the moment the user switches to light mode (or system mode resolves to light), this card keeps its dark literal background while surrounding themed components turn light, breaking visual consistency and likely contrast.
import styled from "styled-components"; const Card = styled.divbackground: ${props => props.theme.surface};;
A CSS Module referencing the existing var(--color-surface) token, as shown in the syntax block above.
Introducing styled-components' own theme-object pattern on an app that already has a working CSS-variable-based ThemeProvider creates two parallel theming systems that can drift out of sync, adds a new runtime dependency, and gives up the zero-JS theme-switching EngiLipi's existing tokens already provide for free.
[multiple-choice] What is the main advantage of CSS Modules over plain global CSS in a React app? A. CSS Modules run faster in the browser B. Class names are scoped locally per file, so two components can't accidentally collide on the same class name C. CSS Modules eliminate the need for a build tool D. CSS Modules automatically add vendor prefixes
B. Class names are scoped locally per file, so two components can't accidentally collide on the same class name — CSS Modules rewrite class names to be unique per file at build time, which is exactly what prevents the naming collisions that plague large plain-CSS codebases.
[multiple-choice] Why should a new EngiLipi component reference var(--color-surface) instead of hardcoding a hex color? A. Hardcoded hex colors are invalid in CSS Modules B. var() is required syntax in React C. The token's actual color changes with the resolved theme (light/dark/system), so referencing it keeps the component correctly themed automatically D. Hex colors are slower to parse than CSS variables
C. The token's actual color changes with the resolved theme (light/dark/system), so referencing it keeps the component correctly themed automatically — EngiLipi's theme.css defines --color-surface differently under light vs. dark data-theme; referencing the token means the component follows whichever theme is active without any component-level theme logic, while a hardcoded hex value would stay fixed regardless of theme.
[multiple-choice] Why is introducing CSS-in-JS (like styled-components) into EngiLipi a questionable choice given the existing architecture? A. CSS-in-JS cannot express colors B. It would create a second, competing theming mechanism alongside the already-working ThemeProvider/CSS-variable system, rather than extending it C. React doesn't support CSS-in-JS libraries D. CSS-in-JS is only for Vue applications
B. It would create a second, competing theming mechanism alongside the already-working ThemeProvider/CSS-variable system, rather than extending it — EngiLipi already has a functioning token-based theme system; layering a separate CSS-in-JS theming approach on top adds a new dependency and a second system that can drift out of sync with the first, for no corresponding benefit.
Rewrite this hardcoded stylesheet rule to use EngiLipi's existing semantic tokens instead: .badge { background: #2f6fed; color: white; border-radius: 6px; }.badge { background: var(--color-primary); color: var(--color-on-accent); border-radius: var(--radius-sm); } background should reference the primary-color token; the border-radius should reference a spacing/radius token rather than a literal pixel value.