Module 5: Forms, Styling and Routing
Styling React Apps: CSS, Modules, Sass, Tailwind and Component Libraries
The real trade-offs between each styling approach, how to set each one up in Vite, and how to choose without overthinking it.
React doesn't have an opinion about styling, which is great until you have to pick one. The list of options is long, and people argue about it loudly online. I'll give you the honest version: every one of these works. They differ in how much they protect you from collisions, how they feel day to day, and how fast they let you move.
Plain CSS#
You write a .css file and import it.
import "./Button.css";
function Button({ children }) {
return <button className="btn">{children}</button>;
}
/* Button.css */
.btn { padding: 8px 16px; border-radius: 6px; }
Vite bundles it for you. The catch is that CSS is global. A class named .btn in one file applies to every .btn in the entire app. Two developers both write .card and styles bleed into places nobody expected. Naming conventions like BEM help but rely on discipline.
CSS Modules#
CSS Modules fix the collision problem. Name your file Something.module.css, and Vite rewrites each class name into a unique one at build time.
/* Button.module.css */
.btn { padding: 8px 16px; border-radius: 6px; }
.primary { background: #2563eb; color: white; }
import styles from "./Button.module.css";
function Button({ variant, children }) {
const className = variant === "primary" ? `${styles.btn} ${styles.primary}` : styles.btn;
return <button className={className}>{children}</button>;
}
source: .btn ──► in the DOM: class="Button_btn__x7a2q"
other file's .btn ──► class="Card_btn__9fk3m" (no clash)
You write ordinary CSS, with real selectors and media queries, and it can't leak. It needs no installation in a Vite project. For conditional class names, a tiny helper called clsx is worth adding:
import clsx from "clsx";
<button className={clsx(styles.btn, variant === "primary" && styles.primary, disabled && styles.disabled)} />
Sass#
Sass adds variables, nesting, mixins and partials on top of CSS. Install the compiler and rename files to .scss. Vite handles the rest.
npm install -D sass
// Button.module.scss
$brand: #2563eb;
.btn {
padding: 8px 16px;
&:hover { background: darken($brand, 8%); }
&.large { padding: 12px 24px; }
}
These days plain CSS has caught up on a lot of it: custom properties (--brand), native nesting, and color-mix(). I'd start without Sass in a new project and add it if your team misses mixins or already uses it everywhere.
Inline styles#
<div style={{ color: "tomato", padding: 12 }}>Hi</div>
Handy for values that are truly dynamic, like a width computed in JavaScript or a position from a drag. They can't express :hover, media queries or pseudo-elements, and they're harder to override. Keep them for the dynamic bits only.
Tailwind CSS#
Tailwind gives you small utility classes (p-4, flex, text-lg) that you compose directly in your markup. You rarely write CSS files.
Setup with Vite and Tailwind v4:
npm install tailwindcss @tailwindcss/vite
// vite.config.js
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";
function Button({ children }) {
return (
<button className="rounded-md bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2">
{children}
</button>
);
}
Reactions to this approach usually go from "that's ugly" to "I can't go back" within a week. What you get: no naming things, no switching files, consistent spacing and colors from a design scale, and unused styles are never shipped. What you give up: long class strings, and a vocabulary to learn. Since React components already give you reuse (you write Button once), the "repeated class strings" problem mostly goes away.
Since setup details change between Tailwind versions, check the official installation guide for Vite when you start.
Component libraries#
If you don't want to design buttons, modals and dropdowns at all, use a library.
- Material UI (MUI): a huge, mature set of components implementing Google's Material Design, themeable, with a lot of built-in functionality like data grids.
- shadcn/ui: different in concept. It isn't a package you install. A CLI copies component source code into your project, built on Radix UI for accessibility and styled with Tailwind. You own the code and can change anything.
- Others worth knowing: Chakra UI, Mantine, Ant Design, and headless libraries such as Radix and React Aria, which give you behavior and accessibility with zero styling.
The trade-off is control and bundle size versus speed. A library gets you a polished admin dashboard in an afternoon. It also makes your app look like that library, and customizing deeply can mean fighting it.
Conditional and dynamic styles#
Whatever approach you choose, you'll switch styles based on props or state. Avoid string-gluing with ternaries for more than two cases. Use clsx or a lookup object.
const sizes = { sm: "px-2 py-1 text-sm", md: "px-4 py-2", lg: "px-6 py-3 text-lg" };
function Button({ size = "md", children }) {
return <button className={`rounded bg-blue-600 text-white ${sizes[size]}`}>{children}</button>;
}
How to choose#
If you want something concrete:
- Learning React, small project: CSS Modules or plain CSS. Nothing to install and nothing to learn on top.
- Building something that will ship fast: Tailwind, optionally with shadcn/ui.
- Internal tool or dashboard where looks matter less than speed: MUI or Mantine.
- Joining an existing team: use what they use. Consistency beats the "best" option.
Pick one and stick with it for a project. Mixing three approaches in one codebase is the real mistake.
Where people get burned#
- Global CSS collisions in a growing app.
- Importing a CSS Module and writing
styles.my-class(hyphens needstyles["my-class"]), or camelCasing in the stylesheet to avoid it. - Building class strings dynamically in Tailwind, like
`bg-${color}-500`. Tailwind scans for complete class names in your source, so partial names are never generated. Use full strings in a lookup. - Overusing inline styles and then being unable to override them.
- Pulling in a giant component library for two buttons.
- Not setting a
viewportmeta tag (Vite's template includes it, don't delete it), then wondering why mobile layouts look broken.
Practice#
- Style a
Cardcomponent three ways: plain CSS, CSS Modules, and inline. Render two copies of each with a conflicting.titleclass and observe which leak. - Add
clsxand make aButtonwithvariantanddisabledprops. - Install Tailwind in a fresh Vite project and recreate the same
Card. - Add
data-themetoggling with CSS custom properties (--bg,--text) and connect it to theThemeProviderfrom the context lesson.