Skip to content
elephantoo

Styling React apps

Lesson 17 of 30 15 min read

Plain CSS, CSS Modules, conditional classes and Tailwind CSS v4 with Vite.


React doesn't have opinions about styling — anything that works on the web works in React. That freedom can be confusing, so this lesson walks through the most common approaches, from plain CSS to Tailwind, and shows how to style components based on props and state.

Plain CSS files#

The simplest approach: write a CSS file and import it. Vite injects it into the page.

src/App.css
.card {
  padding: 1rem 1.25rem;
  border: 1px solid #e5e7eb;
  border-radius: 12px;
}

.card--highlight {
  border-color: #6366f1;
  background: #eef2ff;
}
src/App.jsx
import "./App.css";

function Card({ highlight, children }) {
  return <div className={highlight ? "card card--highlight" : "card"}>{children}</div>;
}

Remember: className, not class. The catch with plain CSS is that every class is global — a .card in one file affects every .card on the page. Naming conventions like BEM (card--highlight) help, but the next option solves it properly.

CSS Modules: scoped by default#

Name a file *.module.css and Vite turns its class names into unique, locally scoped names:

src/components/Button.module.css
.button {
  padding: 0.5rem 1rem;
  border-radius: 8px;
  border: none;
  font: inherit;
  cursor: pointer;
}

.primary {
  background: #4f46e5;
  color: white;
}

.secondary {
  background: #e5e7eb;
  color: #111827;
}

.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
src/components/Button.jsx
import styles from "./Button.module.css";

export function Button({ variant = "primary", children, ...rest }) {
  return (
    <button className={`${styles.button} ${styles[variant]}`} {...rest}>
      {children}
    </button>
  );
}

styles.primary becomes something like _primary_1kq2x_9 in the browser. Another component can have its own .primary with zero conflict. CSS Modules work out of the box in Vite — no install needed.

Combining classes with clsx#

Joining class names with template strings gets messy when there are conditions. The tiny clsx package helps:

Terminal
npm install clsx
JSX
import clsx from "clsx";
import styles from "./Button.module.css";

export function Button({ variant = "primary", size = "md", loading, className, children, ...rest }) {
  return (
    <button
      className={clsx(
        styles.button,
        styles[variant],
        size === "lg" && styles.large,
        { [styles.loading]: loading },
        className // let callers add their own classes
      )}
      disabled={loading || rest.disabled}
      {...rest}
    >
      {loading ? "Saving…" : children}
    </button>
  );
}

clsx ignores false, null and undefined, and includes object keys whose values are truthy.

Inline styles#

The style prop takes an object with camelCased property names:

JSX
function ProgressBar({ percent }) {
  return (
    <div style={{ background: "#e5e7eb", borderRadius: 999, height: 8 }}>
      <div
        style={{
          width: `${percent}%`,
          height: "100%",
          backgroundColor: percent === 100 ? "#16a34a" : "#4f46e5",
          borderRadius: 999,
          transition: "width 300ms ease",
        }}
      />
    </div>
  );
}

Numbers become pixels for most properties (height: 8 → 8px). Inline styles are great for truly dynamic values like a width percentage, but they can't do :hover, media queries or animations, so use classes for everything else.

CSS variables: dynamic values, real CSS#

Combine the two: set a custom property inline, use it in CSS:

JSX
function Avatar({ color, size = 48 }) {
  return <span className="avatar" style={{ "--avatar-color": color, "--avatar-size": `${size}px` }} />;
}
CSS
.avatar {
  display: inline-block;
  width: var(--avatar-size);
  height: var(--avatar-size);
  border-radius: 50%;
  background: var(--avatar-color);
}

.avatar:hover {
  outline: 2px solid var(--avatar-color);
  outline-offset: 2px;
}

CSS variables are also the best way to do themes: set data-theme="dark" on <html> and swap the variables.

CSS
:root {
  --bg: #ffffff;
  --text: #111827;
}

[data-theme="dark"] {
  --bg: #0f172a;
  --text: #e2e8f0;
}

body {
  background: var(--bg);
  color: var(--text);
}
JSX
function ThemeToggle() {
  const [theme, setTheme] = useState("light");
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);
  return <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>Toggle theme</button>;
}

Tailwind CSS#

Tailwind gives you small utility classes (p-4, rounded-lg, text-indigo-600) that you compose directly in JSX. It's hugely popular in React projects. Setting up Tailwind v4 in Vite takes three steps:

Terminal
npm install tailwindcss @tailwindcss/vite
vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});
src/index.css
@import "tailwindcss";

Now use utilities anywhere:

JSX
function LessonCard({ title, minutes, done }) {
  return (
    <article className="rounded-xl border border-gray-200 p-4 shadow-sm transition hover:shadow-md dark:border-gray-700">
      <h3 className="text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
      <p className="mt-1 text-sm text-gray-500">{minutes} min</p>
      <span
        className={clsx(
          "mt-3 inline-block rounded-full px-2 py-0.5 text-xs font-medium",
          done ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-600"
        )}
      >
        {done ? "Completed" : "Not started"}
      </span>
    </article>
  );
}

Variants like hover:, focus:, dark:, md: (≥ 768 px) and lg: handle states and responsiveness. Customise colours and fonts in CSS with @theme:

CSS
@import "tailwindcss";

@theme {
  --color-brand: #6366f1;
  --font-display: "Inter", sans-serif;
}

That makes classes like bg-brand and font-display available.

Tip: always write complete class names. Tailwind scans your source files for class names, so `bg-${color}-500` won't work — use a lookup object like { red: "bg-red-500", green: "bg-green-500" } instead.

CSS-in-JS and component libraries#

  • CSS-in-JS (styled-components, Emotion) lets you write CSS inside JavaScript. It was very popular, but runtime CSS-in-JS doesn't play well with Server Components, so new projects increasingly prefer CSS Modules or Tailwind (or zero-runtime options like vanilla-extract).
  • Component libraries give you ready-made, accessible components: shadcn/ui (copy-in Tailwind components built on Radix), MUI, Mantine and Chakra UI. They're a great way to move fast, but learn the basics first so you can customise them.

Which should I choose?#

ApproachGood for
Plain CSSSmall apps, global styles, resets
CSS ModulesComponent-scoped styles with regular CSS
Inline styles / CSS variablesTruly dynamic values (sizes, colours from data)
Tailwind CSSFast, consistent styling without leaving JSX
Component libraryShipping polished UI quickly

Most real apps mix two: a global stylesheet for resets and variables, plus CSS Modules or Tailwind for components.

Common mistakes#

  • Writing class instead of className, or style="color: red" instead of style={{ color: "red" }}.
  • Using kebab-case in style objects (background-color) instead of camelCase (backgroundColor).
  • Building Tailwind class names dynamically from strings.
  • Relying on global class names that accidentally clash across components.
  • Using inline styles for hover states or media queries (they can't do that).

What's next#

Your app looks great — now let's give it multiple pages. Next: React Router.

Check your understanding

Quick quiz

0/3 answered
  1. 1.In JSX, how do you give an element a CSS class?

  2. 2.What is the main benefit of CSS Modules (Button.module.css)?

  3. 3.How do you add Tailwind CSS v4 to a Vite project?

Finished reading?

Mark this lesson complete to track your progress.