Styling Concepts & Approaches
The full styling landscape — plain CSS, CSS Modules, utility-first (Tailwind), CSS-in-JS (Emotion/Styled), and component libraries (MUI) — with the mechanics, build-time vs runtime cost, scoping strategies, and the decision framework for choosing one (or several) per project.
Why the choice even matters. Every styling approach ultimately produces the same thing the browser understands: CSS rules attached to elements. What differs is where the work happens (build time vs runtime), how styles are scoped (global, per-file, per-component, or per-element), how dynamic the styles can be (static text vs computed from props), and what it costs (bundle size, runtime CPU, developer velocity). Picking a strategy is really picking a set of trade-offs along those axes — so the goal isn't to find the 'best' one, it's to match the axes to your project's constraints.
The scoping problem (the root of everything). CSS is global by default: a .card rule written in one file applies to every .card on the page, and the cascade means the last-loaded rule can silently override a component you never touched. In small projects this is fine; at scale it produces collisions, specificity wars, and the dreaded !important escalation. Almost every 'modern' styling approach exists to solve this one problem — they just solve it differently: naming conventions (BEM), build-time renaming (CSS Modules), atomic classes (Tailwind), or runtime hashing (CSS-in-JS).
Family 1 — plain CSS and CSS Modules. Plain CSS means authoring .css files with real selectors; it is standards-based, has zero runtime cost, and has the best tooling on earth, but it is globally scoped so you manage collisions by convention (methodologies like BEM: .card__title--active). CSS Modules fix scoping at build time: you import a styles.module.css file and get back a JS object whose keys map to uniquely-mangled class names (.card becomes something like Card_card_a1b2c), so two components can both define .card with no conflict. Still zero runtime cost, still real CSS — just automatically namespaced.
Family 2 — utility-first (Tailwind). Instead of writing rules, you compose tiny single-purpose classes directly in markup: class="px-4 py-2 rounded bg-blue-700 text-white". Styling lives next to the element, there are no class names to invent, and a compiler ships only the utilities you actually use, so bundles stay small. The design system is encoded as tokens in a config file, which keeps the whole product visually consistent. The cost is verbose markup and a vocabulary to learn — but scoping is a non-issue because a utility class does exactly one thing everywhere.
Family 3 — CSS-in-JS (Emotion, Styled Components). You write CSS inside JavaScript, colocated with the component, and the library generates a unique scoped class name (usually a hash) at runtime. The killer feature is that styles can be computed from props and state — border-color: ${props => props.active ? 'blue' : 'grey'} — so a component's appearance is a pure function of its inputs. The cost is a runtime library in the bundle, per-render style computation, and extra work to make server-side rendering emit critical CSS. Modern 'zero-runtime' variants (Linaria, vanilla-extract) extract this to static CSS at build time to reclaim the performance.
Family 4 — component libraries (MUI, Chakra, Ant Design). These ship pre-built, pre-styled, accessible components (buttons, dialogs, data grids) plus a theming system, so you assemble UIs instead of styling primitives. They dramatically accelerate product work and bake in accessibility and design consistency for free. The trade-offs are an opinionated API, real effort when you need to customise beyond the theme, and a larger baseline bundle. They fit product teams shipping conventional UIs far better than highly bespoke, performance-critical, or brand-heavy designs.
Build time vs runtime — the axis that decides performance. CSS, CSS Modules, Tailwind, and zero-runtime CSS-in-JS all resolve to static .css files during the build, so the browser does nothing extra — the styles are just there. Classic CSS-in-JS resolves styles while the app runs, which means shipping a styling engine and recomputing styles on renders. On performance-sensitive pages (large lists, low-end devices, tight Core Web Vitals budgets) this difference is measurable, which is why the ecosystem has trended back toward build-time extraction.
Dynamic styling — the axis where CSS-in-JS wins. If a style must change based on a runtime value (a computed theme color, a percentage-based progress bar width, a chart color from data), CSS-in-JS expresses it directly. Plain CSS / Modules / Tailwind handle discrete states via toggling classes (className={active ? styles.on : styles.off}) or CSS custom properties (style={{ '--w': pct + '%' }}), which covers the vast majority of real cases without any runtime library. So 'I need dynamic styles' rarely forces CSS-in-JS — CSS variables are the underrated middle ground.
Mixing approaches is normal, not a smell. Real codebases layer these: a component library for the app chrome, Tailwind for rapid one-off layouts, CSS Modules for a couple of complex bespoke widgets, and CSS custom properties as the shared token layer that ties them together. The pragmatic rule is to standardise on one primary approach for consistency, and reach for another only where its specific strength clearly pays for its cost.
Design tokens — the thing that survives every migration. Whatever family you choose, the durable core is your design tokens: the finite set of colors, spacing steps, font sizes, radii, and shadows. Tailwind puts them in a config, MUI in a theme object, CSS-in-JS in a theme provider, and plain CSS in :root custom properties. If you express tokens as CSS variables, every approach can read them, and you can swap styling engines later without redoing your design system. Invest in the tokens first, the delivery mechanism second.
Common gotchas. (1) Global CSS specificity wars are the number-one reason teams migrate — scope early. (2) Classic CSS-in-JS without SSR setup causes a flash of unstyled content and hydration mismatches. (3) Tailwind's purge/JIT only sees classes that appear as complete literal strings — dynamically concatenated class names (`bg-${color}-500`) get stripped from production. (4) Component libraries are hard to un-adopt; the deep coupling to their API is a real lock-in cost. (5) Inline style={{}} objects bypass the cascade and media queries entirely — fine for one computed value, wrong as a general strategy.
The mental model (memorise this). Every approach is answering three questions — where does the work happen (build vs runtime)?, how are styles scoped (global → per-element)?, and how dynamic must they be (static → prop-driven)? Plain CSS/Modules and Tailwind are build-time, cheap, and static-to-toggled; CSS-in-JS is runtime, costlier, and fully dynamic; component libraries trade control for speed and accessibility. Keep your design tokens portable, pick one primary strategy, and mix only with intent.
Think of styling strategies like choosing how a Spring service manages configuration and dependencies. Plain global CSS is a single shared `application.properties` — convenient until two modules define the same key and clobber each other. CSS Modules are Java packages: build-time namespacing so `com.billing.Card` and `com.reports.Card` never collide. Tailwind is a curated set of reusable `@Component` beans — a fixed vocabulary everyone composes, so the whole app stays consistent. CSS-in-JS is computing configuration at runtime from injected parameters — maximally flexible (styles as a function of props, like beans built from a factory method reading request state) but you pay for that work on every invocation. Component libraries (MUI) are Spring Boot starters: opinionated, batteries-included, accessible defaults that get you shipping fast, at the price of fighting the framework when your needs diverge. Design tokens are your externalised config server — the source of truth that outlives whichever framework consumes it.
- There is no best styling approach — you are choosing trade-offs along three axes: build-time vs runtime work, scoping strategy, and how dynamic styles must be.
- CSS is global by default; nearly every modern approach exists to solve scoping, whether by convention (BEM), build-time renaming (CSS Modules), atomic classes (Tailwind), or runtime hashing (CSS-in-JS).
- Plain CSS and CSS Modules are standards-based with zero runtime cost; Modules add automatic per-file scoping via mangled class names.
- Utility-first (Tailwind) composes single-purpose classes in markup, ships only used utilities, and enforces design tokens, at the cost of verbose markup.
- Classic CSS-in-JS colocates styles with components and computes them from props at runtime, enabling fully dynamic styling but adding bundle size, per-render cost, and SSR complexity.
- CSS custom properties are the underrated middle ground: they give plain CSS and Tailwind dynamic runtime values without any CSS-in-JS library.
- Component libraries like MUI trade control for speed, accessibility, and consistency, but are opinionated and create real lock-in.
- Mixing approaches is normal and healthy when done with intent — standardise on one primary strategy and reach for others only where their strength pays off.
- Design tokens (colors, spacing, type scale) are the durable core; express them as CSS variables so they survive a change of styling framework.
- Tailwind's compiler only sees complete literal class strings — dynamically built class names get purged in production and silently break.
Worked Code
// 1) Plain CSS / CSS Modules — real CSS, scoped class name imported as an object
import styles from "./Button.module.css";
<button className={styles.primary}>Save</button>;
/* Button.module.css: .primary { padding: 8px 16px; background: #1d4ed8; color:#fff; } */
// 2) Utility-first (Tailwind) — compose atomic classes in the markup
<button className="px-4 py-2 rounded bg-blue-700 text-white hover:bg-blue-800">
Save
</button>;
// 3) CSS-in-JS (Emotion styled) — styles colocated, can read props at runtime
import styled from "@emotion/styled";
const Primary = styled.button`
padding: 8px 16px;
background: ${(p: { danger?: boolean }) => (p.danger ? "#dc2626" : "#1d4ed8")};
color: #fff;
`;
<Primary danger>Delete</Primary>;
// 4) Component library (MUI) — assemble a pre-built, accessible component
import Button from "@mui/material/Button";
<Button variant="contained" color="primary">Save</Button>;/* GLOBAL CSS — collides: any .card anywhere is affected, later rule wins */
.card { padding: 16px; }
/* BEM convention — scoping by naming discipline (no tooling) */
.expense-card__title--active { color: #1d4ed8; }
/* CSS MODULES output — bundler mangles the name so it's unique per file */
/* .card -> .ExpenseCard_card_a1b2c (you never type the mangled name) */
/* TAILWIND — atomic classes: each does exactly one thing, everywhere */
/* .px-4 { padding-inline: 1rem } .bg-blue-700 { background:#1d4ed8 } ... */
/* CSS-IN-JS runtime output — a hash class generated per unique style */
/* .css-1a2b3c { padding:16px; } injected into a <style> tag at runtime *//* Define once at the root; any approach below can consume these. */
:root {
--color-brand: #1d4ed8;
--color-danger: #dc2626;
--space-2: 8px;
--space-4: 16px;
--radius: 8px;
}
/* Plain CSS reads them directly */
.button { background: var(--color-brand); padding: var(--space-2) var(--space-4); }
/* Tailwind can map them: theme.extend.colors.brand = 'var(--color-brand)' */
/* CSS-in-JS reads them: background: var(--color-brand); */
/* Because tokens live in CSS vars, swapping frameworks never re-does them. */// A runtime-driven progress bar with plain CSS + a CSS variable.
// No styling library, no per-render style engine — the browser does the work.
function Progress({ percent }: { percent: number }) {
// Pass the runtime value in as a custom property; CSS consumes it.
return (
<div className="track" style={{ ["--pct" as string]: percent + "%" }}>
<div className="fill" />
</div>
);
}
/* CSS:
.track { background:#e5e7eb; border-radius:9999px; overflow:hidden; }
.fill { width: var(--pct); height: 8px; background: var(--color-brand); }
*/type Constraints = {
needsPropDrivenStyles: boolean; // styles computed from runtime values?
perfCritical: boolean; // tight Core Web Vitals / low-end devices?
needsAccessibleComponentsFast: boolean;
teamKnowsTailwind: boolean;
};
function recommendStyling(c: Constraints): string {
if (c.needsAccessibleComponentsFast) return "Component library (MUI/Chakra) + theme tokens";
if (c.perfCritical) return "CSS Modules or Tailwind (build-time, zero runtime)";
if (c.needsPropDrivenStyles) return "CSS custom properties first; CSS-in-JS if truly needed";
if (c.teamKnowsTailwind) return "Tailwind (utility-first)";
return "CSS Modules (safe, standards-based default)";
}▶Try It Live
Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.
Interview-Ready Q&A
I frame it on three axes: where the work happens (build vs runtime), how styles are scoped, and how dynamic they must be. If performance is critical I stay build-time — CSS Modules or Tailwind. If the team wants velocity and consistency and knows utilities, Tailwind. If a lot of styling is genuinely prop-driven I consider CSS-in-JS, but first I check whether CSS custom properties cover it, because they usually do without a runtime library. CSS Modules is my safe default when there's no strong pull otherwise.
- 1No best approach — choose trade-offs on three axes: build vs runtime, scoping, and how dynamic styles must be.
- 2Global CSS is the root problem; every modern approach is a different way to scope.
- 3Plain CSS / CSS Modules = zero runtime cost; Modules add per-file build-time scoping.
- 4Tailwind = atomic classes composed in markup, tiny bundles, enforced tokens, verbose markup.
- 5Classic CSS-in-JS = runtime engine, prop-driven styles, bundle + render cost + SSR setup.
- 6CSS custom properties give dynamic runtime values without any CSS-in-JS library.
- 7Component libraries (MUI) = speed + accessibility + consistency, but opinionated and lock-in.
- 8Mixing is fine with intent: one primary approach, others only where they clearly pay off.
- 9Keep design tokens portable (CSS variables) so they survive a framework change.
- 10Tailwind only sees complete literal class strings — never build class names by concatenation.