React NotesRohit’s interview study guide
Chapter 15

Styling

The ways to style React components — inline styles, global CSS, CSS Modules, styled-components, Tailwind and component libraries like MUI — what each one actually generates, what CSS-in-JS costs at runtime, and how to choose.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Inline styles

From my notes: inline styles are applied with the style attribute, and in React the value is a JavaScript object, not a CSS string. They're scoped to one element by definition and quick for simple cases, but they can't do pseudo-classes, media queries or other selector-based CSS, and they aren't reusable the way a class is.

The mental model: an inline style is CSS with no selector. Every rule in a stylesheet has a selector that says when it applies — .button:hover, @media (max-width: 600px) .button. An inline style only ever applies to "this element, now", so anything conditional on state the browser tracks (hover, focus, viewport width, dark mode) is out of reach.

InlineStyles.jsx
export const MyComponent = () => {
  const buttonStyle = {
    backgroundColor: "blue", // camelCase, not background-color
    color: "white",
    padding: 10, // a number means px: "10px"
    borderRadius: "5px",
    lineHeight: 1.5, // unitless properties stay unitless
  };

  return <button style={buttonStyle}>Click me</button>;
};

// What inline styles can't do: there is no selector, so no :hover, no @media.
export const HoverAttempt = () => {
  const style = {
    backgroundColor: "blue", // works
    ":hover": { backgroundColor: "green" }, // does nothing useful
  };
  return <button style={style}>Hover me</button>;
};
What’s happening
  1. MyComponent builds a plain object and passes it as style. React writes each key onto the element's style, converting camelCase to CSS names. The test prints <button style="background-color: blue; color: white; padding: 10px; border-radius: 5px; line-height: 1.5;">.
  2. padding: 10 became 10px: React appends px to numbers for properties that take lengths. lineHeight: 1.5 stayed 1.5 because React knows line-height (like opacity, zIndex, flex) is unitless. Use strings for anything else ("5px", "2rem", "50%").
  3. In HoverAttempt, backgroundColor works, but ":hover" isn't a CSS property. In the browser React just assigns it as a JavaScript property on the style object: the test shows the DOM as <button style="background-color: blue;"> and button.style[":hover"] as [object Object] — silently ignored, no warning.
  4. On the server it's worse: renderToString emits style="background-color:blue;:hover:[object Object]", literally the object coerced to a string. Garbage CSS in your HTML, again with no warning.
  5. This is the limit from my notes in action: hover, focus, media queries, ::before, keyframes and transitions between states all need a stylesheet.

Where inline styles are the right tool is values that are only known at runtime: a progress bar's width, a colour the user picked, the position of a dragged element. The modern way to combine that with real CSS is to pass the value as a CSS custom property and keep the selectors in a stylesheet:

Chip.jsx
// .chip { background: var(--accent); }
// .chip:hover { background: color-mix(in srgb, var(--accent), black 20%); }
export const Chip = ({ accent, children }) => (
  <span className="chip" style={{ "--accent": accent }}>
    {children}
  </span>
);
What’s happening
  1. The key "--accent" starts with --, so React sets it with style.setProperty("--accent", value) instead of as a normal property. The test renders <Chip accent="tomato"> and gets <span class="chip" style="--accent: tomato;">, and getPropertyValue("--accent") returns tomato.
  2. The stylesheet never changes. .chip reads the variable for its background, and .chip:hover derives a darker shade from it — the hover rule works because it's a real selector in real CSS.
  3. Changing accent only changes one inline custom property; the browser recalculates the styles that use it. No new class names or rules are created (this matters for CSS-in-JS — see CSS-in-JS: costs and alternatives).

#Global CSS stylesheets

From my notes: this is the traditional way. You write CSS in a .css file and import it into a component; the bundler adds it to the page and the styles apply globally, exactly like a <link rel="stylesheet"> in plain HTML. It's simple, everyone understands it, and every CSS feature works. The cost is that every class name lives in one global namespace, so two files that both define .button fight, and the winner is whichever loads last.

The mental model: import "./styles.css" doesn't attach the CSS to the component that imports it. It's a side effect that adds the stylesheet to the whole document, once. The import only decides when it's loaded and in what order, never where it applies.

styles.css
/* styles.css: global, every .button on the page gets this */
.button {
  background-color: blue;
  color: white;
  padding: 10px;
  border-radius: 5px;
}
Card.css
/* Card.css, written by another team: also global! */
.button {
  background-color: hotpink;
}
MyComponent.jsx
import "./styles.css";
import "./Card.css";

export const MyComponent = () => <button className="button">Click me</button>;
What’s happening
  1. Both imports are side effects. In development Vite injects each file as a <style> tag; in a production build it concatenates them into one .css file. The real build output is styles.css's rule followed by Card.css's, both still named .button — nothing is renamed.
  2. The button has class="button", so both rules match it. They have equal specificity, so the later one wins for the properties they both set: background-color: hotpink. The test runs this in jsdom with CSS processing on and getComputedStyle(button).backgroundColor is rgb(255, 105, 180) — hot pink.
  3. color, padding and border-radius still come from styles.css, because Card.css doesn't set them. Rules merge property by property.
  4. Nobody wrote a bug: Card.css was meant for the card's own buttons. But the import order — which can change when someone reorders imports or a route is lazy-loaded first — now decides what every button on the site looks like.

Global CSS is still the right tool for things that should be global: a reset (box-sizing, margins), base typography, CSS variables for the design tokens (:root { --brand: … }), and styles for third-party widgets. For component styles, teams that use plain global CSS protect themselves with naming conventions such as BEM (.card__button--primary), or with cascade layers (@layer reset, base, components, utilities;), which make the order of precedence explicit instead of depending on import order.

#CSS Modules

From my notes: CSS Modules solve the global-scope problem by scoping class names to the file. You write normal CSS in a *.module.css file; at build time every class gets a unique generated name, and importing the file gives you an object that maps your names to the generated ones. The styles are encapsulated by default, and it's still plain CSS.

The mental model: a CSS Module is a CSS file with its own namespace, like a JavaScript module whose top-level variables don't leak. .button in MyComponent.module.css and .button in Card.module.css are different classes, because neither file's class is actually called button in the browser.

MyComponent.module.css
.button {
  background-color: blue;
  color: white;
  padding: 10px;
  border-radius: 5px;
}

.primary {
  composes: button; /* reuse another class from this file */
  font-weight: bold;
}

.button:hover {
  background-color: navy; /* pseudo-classes work: it's real CSS */
}

@media (max-width: 600px) {
  .button {
    width: 100%;
  }
}

:global(.dark) .button {
  background-color: black; /* .dark stays unhashed */
}
MyComponent.jsx
import styles from "./MyComponent.module.css";

export const MyComponent = () => <button className={styles.button}>Click me</button>;
export const PrimaryButton = () => <button className={styles.primary}>Save</button>;
What’s happening
  1. At build time Vite's CSS Modules support (built in for any *.module.css file) renames every class. The real production build output is ._button_10eci_1 { … }, ._primary_10eci_8 { … }, ._button_10eci_1:hover { … } and @media (max-width: 600px) { ._button_10eci_1 { … } } — the pattern is _[name]_[file hash]_[line].
  2. The JavaScript side becomes a plain object. The built bundle contains { button: "_button_10eci_1", primary: "_primary_10eci_8 _button_10eci_1" }, so className={styles.button} renders class="_button_10eci_1". Nothing happens at runtime except reading a string from an object.
  3. composes: button doesn't copy CSS. It makes styles.primary contain two class names, "_primary_… _button_…", so the element gets both rules. That's why the .primary rule in the output only has font-weight: bold.
  4. Pseudo-classes and media queries work because the output is ordinary CSS with renamed selectors. :global(.dark) opts one selector out of renaming, giving .dark ._button_10eci_1 — useful for a theme class set on <html>.
  5. A test renders the global .button from the previous topic and this module's button in the same document: the global one computes to rgb(255, 105, 180), the module one to rgb(0, 0, 255). The hot-pink rule can't reach it, because no element in the module's world has the class button.

Correcting my notes: they list "extra setup required (configuring Webpack for non-CRA projects)" as the main con. That was true of hand-rolled Webpack setups, where you enabled modules in css-loader. Today Vite, Next.js, Parcel and Rspack support *.module.css with no configuration. The real trade-offs are elsewhere: styles live in a separate file from the component; dynamic values still need inline styles or CSS variables; class names are strings, so a typo (styles.buton) is undefined at runtime unless you add TypeScript declarations for the module; and there's no built-in theming beyond CSS variables.

#Sass and SCSS

Added

Sass is a CSS preprocessor: a language that adds variables, nesting, mixins, functions and loops on top of CSS, and is compiled to plain CSS at build time. The browser never sees Sass. It doesn't run on the server when a page is requested, and it doesn't run in the browser: your build tool (Vite, webpack, Next.js) runs the Sass compiler once while building, and what ships is an ordinary .css file. A common misconception is that Sass is "executed on the server"; the only place it runs is your machine or CI during the build (and the dev server, which compiles on the fly while you work).

Sass has two syntaxes. SCSS (.scss) is a superset of CSS: braces and semicolons, and any valid CSS file is valid SCSS. The older indented syntax (.sass) uses indentation instead of braces. Nearly everyone uses SCSS, and "Sass" usually means the language and its compiler. The compiler today is Dart Sass, published on npm as sass (pure JavaScript) and sass-embedded (a faster native binary); the old node-sass/LibSass is deprecated.

The mental model: Sass is a recipe that gets cooked before serving. You write with shortcuts (variables, reusable mixins, loops) and the build turns it into the finished, plain CSS dish. Whatever Sass computed, such as 4px * 2, is a fixed number by the time the browser gets it.

Setup with Vite is one command and no config: Vite has built-in support for .scss, .sass and .module.scss files, and only needs the compiler installed.

Shell
npm i -D sass            # or: npm i -D sass-embedded (faster; Vite tries it first)

Without it, the first .scss import fails the build with Vite 8's message: Preprocessor dependency "sass-embedded" not found. Did you install it? Try `npm install -D sass-embedded`.

A partial with shared tokens, and a Sass module that uses it:

_tokens.scss
// A partial: the leading underscore means "only for @use, never compiled on its own"
$brand: #2563eb;
$radius: 6px;
$variants: (
  "danger": #dc2626,
  "success": #16a34a,
);

@function space($n) {
  @return $n * 4px;
}

@mixin focus-ring($color: $brand) {
  outline: 2px solid $color;
  outline-offset: 2px;
}
Button.module.scss
@use "tokens" as t;

.button {
  background: t.$brand;
  color: white;
  padding: t.space(2) t.space(4);
  border-radius: t.$radius;

  &:focus-visible {
    @include t.focus-ring;
  }

  .icon {
    margin-right: t.space(1);
  }
}

@each $name, $color in t.$variants {
  .#{$name} {
    background: $color;

    &:focus-visible {
      @include t.focus-ring($color);
    }
  }
}
Button.jsx
import styles from "./Button.module.scss";

export function Button({ variant, children }) {
  const className = variant ? `${styles.button} ${styles[variant]}` : styles.button;
  return (
    <button className={className}>
      <span className={styles.icon}>★</span>
      {children}
    </button>
  );
}

The CSS that vite build (Vite 8.3, Sass 1.105) actually produced, unminified:

index.css
._button_ycdfx_1 {
  background: #2563eb;
  color: white;
  padding: 8px 16px;
  border-radius: 6px;
}
._button_ycdfx_1:focus-visible {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
._button_ycdfx_1 ._icon_ycdfx_11 {
  margin-right: 4px;
}

._danger_ycdfx_15 {
  background: #dc2626;
}
._danger_ycdfx_15:focus-visible {
  outline: 2px solid #dc2626;
  outline-offset: 2px;
}

._success_ycdfx_23 {
  background: #16a34a;
}
._success_ycdfx_23:focus-visible {
  outline: 2px solid #16a34a;
  outline-offset: 2px;
}
What’s happening
  1. @use "tokens" as t loads _tokens.scss (the underscore and extension are implied) as a module with a namespace: its variables, functions and mixins are reached as t.$brand, t.space(), t.focus-ring. Nothing leaks into the global scope, and the partial is never output as its own CSS file.
  2. Sass runs first. t.$brand becomes #2563eb, t.space(2) t.space(4) is evaluated by the @function to 8px 16px, and t.space(1) to 4px. These are fixed values in the output: Sass variables don't exist at runtime.
  3. Nesting: &:focus-visible inside .button becomes .button:focus-visible (& is the parent selector), and the nested .icon becomes the descendant selector .button .icon. @include t.focus-ring pastes the mixin's two declarations in, with $color defaulting to the brand colour.
  4. @each loops over the $variants map and generates one class per entry, .danger and .success, each with its own focus ring colour passed to the mixin. That's something plain CSS can't do.
  5. Then the CSS Modules step (because the file is *.module.scss) renames every class, exactly as for .module.css in CSS Modules: the JavaScript gets { button: "_button_ycdfx_1", icon: "_icon_ycdfx_11", danger: "_danger_ycdfx_15", success: "_success_ycdfx_23" }.
  6. In a Vitest test with css: true, <Button variant="danger"> got the classes _button_6ac91b _danger_6ac91b (the test environment uses its own naming pattern) and a computed background of rgb(220, 38, 38), while a plain <Button> computed to rgb(37, 99, 235) with padding 8px 16px.

How much of Sass modern CSS now covers. Since about 2023 browsers support much of what people originally reached for Sass to get:

Sass featureModern CSS equivalentDifference
Variables ($brand)Custom properties (--brand, var(--brand))Custom properties exist at runtime: they cascade, can change in a media query or a .dark class, and can be set from JavaScript. Sass variables are gone after the build
Nesting and &Native CSS nestingSupported in all current browsers since 2023, but & can't build new class names: Sass's BEM-style &__icon (giving .card__icon) has no native equivalent
Colour functionscolor-mix(), relative coloursComputed by the browser, so they work with custom properties
Partials and @use@import of CSS files, or the bundler's CSS importsCSS @import has no namespaces; bundlers inline it
Mixins, @function, @each, mapsNo full equivalent yetStill Sass-only

When Sass is still worth it: a design system or codebase that already uses it (Bootstrap's source is Sass), when you need mixins with arguments, loops or maps to generate many classes, or a namespaced module system for tokens. When to skip it: a new app with CSS Modules and custom properties already has scoping, variables and nesting, with one less build dependency; and with Tailwind (whose v4 docs advise against combining it with Sass) or CSS-in-JS, JavaScript already does what Sass would. Many teams now use Sass only for its mixins and keep tokens as CSS custom properties, so themes can change at runtime.

#styled-components

From my notes: styled-components is a CSS-in-JS library: you write real CSS inside JavaScript using tagged template literals, and the result is a React component with those styles attached. It keeps styles scoped to the component, supports dynamic styling based on props, and gives you the full range of CSS. My notes add that it's "super powerful" because you can pass props, inherit styles, and use pseudo-classes and media queries — those three examples were screenshots that didn't survive, so the ones below are new and run against styled-components 6.5.

The mental model: styled.button is a component factory. When the component renders, styled-components evaluates the template (calling any ${(props) => …} functions with the current props), hashes the resulting CSS into a class name, inserts the rule into a <style> tag if it isn't there yet, and renders a <button> with that class. Different CSS text → different class.

Styled.jsx
import styled, { css, ThemeProvider, createGlobalStyle } from "styled-components";

// 1. The basic example: a <button> with these styles attached.
export const Button = styled.button`
  background-color: blue;
  color: white;
  padding: 10px;
  border-radius: 5px;

  /* 2. Pseudo-classes: & means "this component's generated class" */
  &:hover {
    background-color: navy;
  }
  &:disabled {
    opacity: 0.5;
  }

  /* 3. Media queries, nested right inside */
  @media (max-width: 600px) {
    width: 100%;
  }
`;

// 4. Props: a $-prefixed ("transient") prop is read by the styles but never reaches the DOM.
export const PropsButton = styled.button`
  padding: 10px;
  color: white;
  background-color: ${(props) => (props.$variant === "danger" ? "crimson" : "blue")};
  ${(props) =>
    props.$large &&
    css`
      font-size: 1.25rem;
      padding: 16px 24px;
    `}
`;

// 5. Inheritance: extend an existing styled component; only the differences are written.
export const OutlineButton = styled(Button)`
  background-color: transparent;
  color: blue;
  border: 2px solid blue;
`;

// 6. Theme: values come from the nearest <ThemeProvider>.
export const ThemedButton = styled.button`
  background-color: ${({ theme }) => theme.colors.primary};
  color: ${({ theme }) => theme.colors.onPrimary};
`;

export const GlobalStyle = createGlobalStyle`
  body { margin: 0; font-family: system-ui, sans-serif; }
`;

export const theme = { colors: { primary: "#1976d2", onPrimary: "white" } };

export function App() {
  return (
    <ThemeProvider theme={theme}>
      <GlobalStyle />
      <Button>Click me</Button>
      <PropsButton $variant="danger" $large>
        Delete
      </PropsButton>
      <OutlineButton>Cancel</OutlineButton>
      <ThemedButton>Themed</ThemedButton>
      {/* 7. "as": keep the styles, render a different element */}
      <Button as="a" href="/docs">
        Docs link
      </Button>
    </ThemeProvider>
  );
}
What’s happening
  1. Button renders <button class="sc-bdvwhi fqbLpy">. The first class (sc-…) is a stable id for the component; the second (fqbLpy) is the hash of its CSS. The injected rules are .fqbLpy{background-color:blue;…}, .fqbLpy:hover{background-color:navy;}, .fqbLpy:disabled{opacity:0.5;} and @media (max-width: 600px){.fqbLpy{width:100%;}} — the & became the generated class, and the nested @media was lifted out into a real media query.
  2. PropsButton receives $variant="danger" and $large. The interpolation functions run with those props, so the final CSS is padding:10px;color:white;background-color:crimson;font-size:1.25rem;padding:16px 24px; under the class kTTEas. The css helper lets a whole block be conditional; the later padding overrides the earlier one, as in any CSS.
  3. The $ prefix makes these transient props: styled-components uses them for styling and doesn't pass them to the DOM, so the rendered element is just <button class="sc-gsDMPd kTTEas">Delete</button>.
  4. OutlineButton = styled(Button) renders with both sets of classes: class="sc-bdvwhi sc-dkPwfY fqbLpy fwGMYO". The base rule .fqbLpy still applies (padding, radius, hover) and the extension's rule .fwGMYO{background-color:transparent;color:blue;border:2px solid blue;} is inserted later, so it wins where they overlap. jsdom computes its background as rgba(0, 0, 0, 0) — transparent.
  5. ThemedButton reads theme from <ThemeProvider> (React context under the hood) and generates .eYyGDo{background-color:#1976d2;color:white;}; the computed background is rgb(25, 118, 210). Change the theme object and every themed component restyles.
  6. <Button as="a" href="/docs"> keeps Button's classes (sc-bdvwhi fqbLpy) but renders an <a href="/docs"> — the right element for navigation, with button styling.
  7. createGlobalStyle creates a component that injects global CSS (here, the body reset) while it's mounted. It's the CSS-in-JS version of a global stylesheet.
AdvancedServer rendering and React Server Components

On the server there's no document to inject <style> tags into, so for classic SSR you collect the styles during rendering and put them in the HTML yourself:

ssr-styles.jsx
import { renderToString } from "react-dom/server";
import { ServerStyleSheet } from "styled-components";
import { Button } from "./Styled";

const sheet = new ServerStyleSheet();
try {
  const html = renderToString(sheet.collectStyles(<Button>Buy</Button>));
  const styleTags = sheet.getStyleTags(); // a string of <style> tags for <head>
  // send `<head>${styleTags}</head><body><div id="root">${html}</div>…`
} finally {
  sheet.seal(); // free the sheet
}
What’s happening
  1. sheet.collectStyles(element) wraps the tree in a provider that records every rule generated while it renders, instead of writing them to a DOM that doesn't exist.
  2. renderToString returns the markup: <button class="sc-bdvwhi fqbLpy">Buy</button> (run in a Node test environment).
  3. getStyleTags() returns a <style data-styled="true" data-styled-version="6.5.3"> tag containing .fqbLpy{…}, the hover, disabled and media rules, plus a data-styled.g1[id="sc-bdvwhi"]{content:"fqbLpy,"} marker that tells the client which classes already exist so it doesn't insert them again on hydration.
  4. Put the tags in <head> so the first paint is styled; without them the page flashes unstyled until JavaScript runs. Frameworks wrap this for you; for the Next.js App Router, both projects document a small "registry" client component that does the same collection with useServerInsertedHTML.
  5. React Server Components can't use context, which is how ThemeProvider works. styled-components 6.3 added native RSC support (styles are emitted as <style> tags React hoists; ThemeProvider is a no-op there) and 6.4 added createTheme, which turns a theme object into CSS variables so server components can be themed without context. Older versions, and Emotion, need 'use client' on every styled component.

Pros and cons, from my notes and updated: component-scoped styles, full CSS support, dynamic styling with props, theming and no separate CSS file are the pros. The cons in my notes — a learning curve and "larger bundle size if not optimized" — are real but small (about 13 kB gzipped); the bigger cost is runtime work on every render and the friction with server rendering, covered in CSS-in-JS: costs and alternatives.

Correcting my notes: they say styled-components is "most commonly used… highly popular in modern React projects". That was true around 2018–2022. Since then the ecosystem has moved towards zero-runtime approaches (Tailwind, CSS Modules, compile-time CSS-in-JS), the React team discourages runtime style injection, and styled-components' own README notes it is largely maintained by one person. It's still everywhere in existing codebases — so know it well for interviews and maintenance — but it's rarely the default choice for a new React 19 app.

#Tailwind CSS

From my notes: Tailwind is a utility-first CSS framework: instead of writing custom CSS, you style elements by combining small predefined classes directly in your JSX. The pros in my notes still hold — no custom CSS required, responsive design built in, highly customisable, consistent — and so does the con: "class name soup", long className strings that are unfamiliar to people used to writing their own styles. My notes' summary of where it fits also stands: projects where rapid development and consistency matter, in teams that prefer utilities to custom class names.

The mental model: Tailwind is a design system expressed as class names. px-4 isn't "padding 16px", it's "horizontal padding of 4 steps on the spacing scale"; bg-blue-500 is "the 500 shade of the palette's blue". Because everyone picks from the same scale, spacing and colours stay consistent without a style guide. And a build-time scanner reads your source files and generates CSS only for the classes it finds, so the stylesheet contains exactly what you use.

Tailwind.jsx
export const MyComponent = () => {
  return <button className="bg-blue-500 text-white py-2 px-4 rounded">Click me</button>;
};

// Variants are prefixes: hover:, focus-visible:, md: (min-width 48rem), dark:, disabled:
export const ResponsiveCard = ({ selected }) => (
  <div
    className={[
      "rounded-lg p-4 shadow",
      "flex flex-col md:flex-row", // stacked on phones, side by side from md up
      "bg-white dark:bg-slate-800",
      selected ? "ring-2 ring-brand" : "ring-0", // full class names in both branches
    ].join(" ")}
  >
    <button className="bg-brand hover:bg-brand/80 disabled:opacity-50 px-3 py-1">Save</button>
  </div>
);
app.css
@import "tailwindcss";

@theme {
  --color-brand: #7c3aed; /* creates bg-brand, text-brand, ring-brand, ... */
}
What’s happening
  1. The scanner (run here with the Tailwind 4.3.3 CLI) finds bg-blue-500, text-white, py-2, px-4 and rounded in the source and generates one rule each. The real output: .bg-blue-500 { background-color: var(--color-blue-500); }, .px-4 { padding-inline: calc(var(--spacing) * 4); }, .py-2 { padding-block: calc(var(--spacing) * 2); }, .rounded { border-radius: 0.25rem; }.
  2. Tailwind 4 is built on CSS variables: --spacing is 0.25rem, so px-4 is 1rem of horizontal padding, and --color-blue-500 is oklch(62.3% 0.214 259.815). Change a variable and every utility that uses it follows.
  3. Variants become real selectors and at-rules: md:flex-row is wrapped in @media (width >= 48rem), dark:bg-slate-800 in @media (prefers-color-scheme: dark), and hover:bg-brand/80 in @media (hover: hover) { .hover\:bg-brand\/80:hover { … } } — Tailwind 4 only applies hover styles on devices that can hover, so taps on phones don't leave "stuck" hover colours. The /80 is an opacity modifier, compiled to color-mix(in oklab, var(--color-brand) 80%, transparent).
  4. @theme { --color-brand: #7c3aed; } adds a colour to the design system, and from then on bg-brand, ring-brand and text-brand exist. This CSS-first configuration replaces Tailwind 3's tailwind.config.js.
  5. ResponsiveCard builds its className from plain strings: the test renders selected and gets class="rounded-lg p-4 shadow flex flex-col md:flex-row bg-white dark:bg-slate-800 ring-2 ring-brand". There's no runtime library — the browser just matches classes against a static stylesheet.

Setting it up in a Vite project, per the Tailwind 4 docs: npm install tailwindcss @tailwindcss/vite, add tailwindcss() to the Vite plugins, and put @import "tailwindcss"; at the top of your main CSS file. There's no content array to configure any more; the scanner finds your source files automatically (with @source to add paths it would skip).

When class lists get long, teams extract components, not CSS: a <Button variant="primary"> that owns its classes, often with a helper like clsx for conditions and tailwind-merge to resolve conflicts (px-2 vs px-4) when callers pass extra classes. Avoid recreating the old world with @apply everywhere; it works, but you lose the point of utilities.

#Component libraries: MUI and theming

A component library gives you finished, accessible components — buttons, dialogs, date pickers, data grids — with a consistent design and a theming system. From my notes: MUI (Material UI) implements Google's Material Design, and you theme it by creating a theme with createTheme and providing it to the app with ThemeProvider.

The mental model: a component library is a design system you rent. You get months of work on keyboard handling, focus traps, ARIA attributes and edge cases for free, and you pay in two ways: your app looks like the library unless you put real effort into theming, and you take on its styling engine and bundle size. The theme object is the contract between them — change palette.primary.main once and every component that uses "primary" follows.

The examples from my notes, updated for Material UI v9 (MUI isn't installed in the sandbox, so this code wasn't executed; it follows the v9 docs):

Theme.jsx
// npm install @mui/material @emotion/react @emotion/styled
import Button from "@mui/material/Button";
import CssBaseline from "@mui/material/CssBaseline";
import { ThemeProvider, createTheme } from "@mui/material/styles";

const theme = createTheme({
  palette: {
    primary: {
      main: "#1976d2",
    },
  },
  colorSchemes: { dark: true }, // light is on by default; this adds a dark palette
  shape: { borderRadius: 8 },
});

function MyComponent() {
  return (
    <Button variant="contained" color="primary" sx={{ mt: 2, px: 4 }}>
      Click Me
    </Button>
  );
}

export function MyApp() {
  return (
    <ThemeProvider theme={theme}>
      <CssBaseline /> {/* MUI's global reset + base typography */}
      <MyComponent />
    </ThemeProvider>
  );
}
What’s happening
  1. createTheme merges your overrides into MUI's default theme. Only palette.primary.main is given; MUI derives the rest (light, dark and a readable contrastText) from it. shape.borderRadius changes the corner radius of every component that uses the theme's shape.
  2. colorSchemes: { dark: true } adds a dark palette alongside the default light one; the user's system preference (or the useColorScheme hook) picks between them. The docs warn that mode from useColorScheme is undefined on the first render, to avoid hydration mismatches.
  3. ThemeProvider puts the theme in React context. <Button variant="contained" color="primary"> reads theme.palette.primary and renders a filled button in #1976d2, with hover, focus-ring and ripple behaviour built in.
  4. sx is MUI's one-off styling prop: mt: 2 means margin-top of 2 theme spacing units (8px each by default, so 16px) and px: 4 horizontal padding of 32px. It accepts theme values, breakpoints ({ px: { xs: 2, md: 4 } }) and pseudo-selectors ("&:hover": { … }).
  5. CssBaseline is MUI's global layer: a CSS reset and base typography, the same role a global stylesheet plays in the Global CSS stylesheets topic.
  6. Under the hood MUI's default styling engine is Emotion, a runtime CSS-in-JS library like styled-components — which is why the install command includes @emotion/react and @emotion/styled, and why the costs in the next topic apply to MUI too.

Both import { createTheme, ThemeProvider } from "@mui/material" (as in my notes) and from "@mui/material/styles" (as in the current docs) work; the root package re-exports the styles entry. Other theming tools worth knowing: theme.components.MuiButton.styleOverrides / defaultProps to change a component everywhere, styled() from @mui/material/styles to build your own themed components, and cssVariables: true to emit the theme as CSS variables, which the docs recommend to avoid a light-mode flash on server-rendered dark-mode pages.

Other libraries interviewers mention, by approach:

LibraryWhat it isStyling
MUI (Material UI)Full Material Design component set + MUI X (data grid, date pickers)Emotion (runtime CSS-in-JS) by default
Chakra UI, MantineFull component sets with their own design languageRuntime or CSS-variable based, theme objects
Ant DesignEnterprise-style components, big data tables and formsCSS-in-JS with design tokens
Radix UI, React Aria, Headless UIHeadless: behaviour and accessibility only, no stylesBring your own (often Tailwind)
shadcn/uiNot a dependency: a CLI copies component source (Radix + Tailwind) into your repoTailwind

#CSS-in-JS: costs and alternatives

My notes mark "Potential issues with CSS-in-JS approach" as IMPORTANT, followed by "Mitigate performance issues" — both were screenshots that didn't survive. So the issues and mitigations below are written fresh, and the first one is measured.

The idea to hold on to: runtime CSS-in-JS (styled-components, Emotion, MUI's default engine) does at runtime, in the browser, on every render what other approaches do once at build time. To render a styled component, the library evaluates the template with the current props, serialises the CSS, hashes it, checks whether that class exists, and if not, parses and inserts a new rule into the stylesheet. That's flexible — any prop can change any style — and it's work the browser repeats for every user, on every render, on the main thread.

Costs.jsx
import styled from "styled-components";

// Interpolating a fast-changing value: every distinct value = a new class + a new CSS rule.
export const BarInterpolated = styled.div`
  height: 8px;
  background: teal;
  width: ${(p) => p.$percent}%;
`;

// Same look, static CSS: the changing value goes through a CSS variable instead.
const StaticBar = styled.div`
  height: 8px;
  background: teal;
  width: var(--percent);
`;
export const BarWithVariable = ({ percent }) => <StaticBar style={{ "--percent": `${percent}%` }} />;
What’s happening
  1. The test re-renders BarInterpolated with 250 different widths (0%, 0.1%, … 24.9%), as a slider or progress bar would.
  2. Each new $percent produces different CSS text, so a new hash, a new class and a new rule: the stylesheet starts .cKfKFv{height:8px;background:teal;width:0%;}.gRTPWo{height:8px;background:teal;width:0.1%;}…. After 250 values there are 250 rules. They're never removed, even after the component unmounts.
  3. Past 200, styled-components warns in development: "Over 200 classes were generated for component styled.div with the id of "sc-bdvwhi". Consider using the attrs method, together with a style object for frequently changed styles."
  4. BarWithVariable keeps the CSS static and passes the width through a CSS custom property in style. The same 250 renders add exactly 1 rule in total. The browser just updates one inline property per render.
  5. This is the general shape of the problem: dynamic interpolations turn every distinct value into stylesheet work. The mitigation is to keep the CSS static and move changing values into CSS variables, inline styles or data-* attributes with matching selectors.

The costs, in the order interviewers usually ask about them:

  • Runtime work on every render — serialising and hashing styles costs time on the main thread; in large trees and frequent updates (animations, typing) it shows up in the profiler.
  • Style insertion during rendering — every new rule makes the browser recalculate styles. React added useInsertionEffect for library authors so rules can be inserted before layout effects read the DOM (Emotion uses it); styled-components 6 inserts while rendering (its bundle doesn't reference the hook). Either way the recalculation still happens.
  • Bundle size — the library itself ships to every user (about 13 kB gzipped for styled-components, similar for Emotion), and styles are JavaScript, which is more expensive to download and parse than the same CSS.
  • Server rendering — styles must be collected during SSR and sent in the HTML (ServerStyleSheet, Emotion's cache), and streaming SSR makes this harder because styles for later chunks arrive later.
  • React Server Components — context-based theming can't run on the server, so historically every styled component needed 'use client'. styled-components 6.3+ now handles RSC natively, but Emotion (and so MUI's default engine) still needs client components.
  • Unbounded rule growth — as measured above, dynamic interpolations create rules that are never cleaned up.

How to mitigate if you're staying on runtime CSS-in-JS:

  • Define styled components at module level, never inside another component's body. Inside a component, each render creates a brand-new component type, so React unmounts and remounts the subtree (losing state) and the library generates new classes.
  • Move fast-changing values out of interpolations into CSS variables or .attrs(() => ({ style: { … } })), as the warning suggests.
  • Prefer a few variants over arbitrary values: $variant="danger" produces one extra class; $color={anyString} produces one per colour.
  • Memoise expensive subtrees (React.memo, the React Compiler) so unchanged styled components don't re-render at all.
  • Use the SSR integration so first paint isn't unstyled.

And the alternatives, which avoid the runtime entirely:

  • CSS Modules and Tailwind — static CSS generated at build time; zero runtime.
  • Compile-time CSS-in-JS — write styles in JavaScript/TypeScript with types and theme tokens, and the build extracts them into static .css files: vanilla-extract, Linaria, Panda CSS, Meta's StyleX, and MUI's Pigment CSS. You keep the colocation and type safety without paying per render.
Spot the problem
React
function ProductCard({ product }) {
  const Title = styled.h2`
    color: ${product.onSale ? "crimson" : "black"};
  `;
  const [expanded, setExpanded] = useState(false);
  return (
    <article onClick={() => setExpanded(!expanded)}>
      <Title>{product.name}</Title>
      {expanded && <p>{product.description}</p>}
    </article>
  );
}
Show answer

Title is created inside the component, so every render of ProductCard creates a new styled component type.

What’s happening
  1. On the first render, styled.h2 runs and returns a component; React mounts <Title> and styled-components inserts a rule for it.
  2. A click sets expanded, so ProductCard renders again — and styled.h2 runs again, returning a different component function. The element type at that position changed.
  3. React compares element types by identity. A different type means "different component": it unmounts the old <h2> subtree and mounts a new one. A test keeps a reference to the <h2> before the click: afterwards it's a different node, and the old one is no longer in the document. Any state inside Title's subtree, focus or text selection would be lost.
  4. styled-components registers a new component with a new id on every render (sc-bdvwhi, then sc-gsDMPd), repeating the style work, and warns each time: "The component styled.h2 with the id of "sc-bdvwhi" has been created dynamically. You may see this warning because you've called styled inside another component."
  5. The fix: define Title once at module level with a transient prop — const Title = styled.h2 + `color: ${(p) => (p.$onSale ? "crimson" : "black")};` — and render <Title $onSale={product.onSale}>.

#Choosing a styling approach

There's no single right answer, but there is a sensible default and a clear set of trade-offs. The questions that decide it: Does styling work happen at build time or runtime? (performance, server rendering, Server Components.) Where do styles live? (next to the component or in a separate file.) How are dynamic values handled? (props, variants, CSS variables.) How is theming done? (CSS variables, a theme object, a design scale.)

ApproachScopingRuntime costDynamic valuesThemingServer Components
Inline stylesPer elementNoneEasyVia CSS variablesYes
Global CSSNone (global)NoneCSS variables, classesCSS variablesYes
CSS ModulesPer file (build-time renaming)NoneCSS variables, class togglesCSS variablesYes
styled-components / EmotionPer component (runtime classes)Yes, every renderProps interpolationThemeProvider (context)styled-components 6.3+; Emotion needs 'use client'
TailwindNo custom class namesNoneClass toggles, CSS variables@theme design tokensYes
Compile-time CSS-in-JSPer component (build-time)NoneVariants, CSS variablesTyped tokensYes
MUI / component libraryLibrary-managedEmotion's runtime by defaultsx, propscreateTheme + ThemeProviderClient components

How I'd answer "how would you style a new React 19 app?":

  • Default: Tailwind or CSS Modules for component styles, plus a thin global layer (reset, CSS variables for tokens, typography). Both are zero-runtime and work with SSR and Server Components.
  • Design-heavy product with its own brand: Tailwind + headless primitives (Radix/React Aria, or shadcn/ui), or compile-time CSS-in-JS if the team wants typed style objects.
  • Internal tool or dashboard: a full component library such as MUI, accepting its runtime cost for speed of delivery.
  • Existing styled-components codebase: don't rewrite for its own sake. Fix the hot spots (dynamic interpolations, components defined inside components), use transient props, and adopt CSS variables for theming.
  • Everywhere: dynamic values through CSS variables, not by generating new CSS.
Which approach fits?

A marketing site built with Next.js App Router, mostly Server Components, with a light/dark theme and a strict performance budget. The team currently uses Emotion. What would you suggest and why?

Show answer

Move to a zero-runtime approach — Tailwind or CSS Modules with CSS variables for the theme — and keep Emotion only inside the few client components that need it during a migration.

What’s happening
  1. "Mostly Server Components" rules out context-based styling: Emotion's styled components need 'use client', which turns styled leaves into client components and pulls their JavaScript into the bundle.
  2. "Strict performance budget" argues against any runtime style generation: zero-runtime CSS ships as cacheable static files and does no work during render or hydration.
  3. The light/dark theme is easiest as CSS variables: :root { --bg: white } [data-theme="dark"] { --bg: #111 }. Both Tailwind (@theme tokens plus a dark: variant) and CSS Modules (var(--bg)) read them, server components included, with no provider.
  4. A full rewrite isn't required on day one: new and touched components use the new approach, interactive client components can keep Emotion until they're migrated, and the two coexist because both are just CSS in the end.

Built from Rohit’s “React Interview concepts” doc. Examples target React 19.3 and are tested with Vitest and React Testing Library.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.