React has no styling system. It renders DOM elements, and you style them the way you style any DOM element — with the two or three React-specific details in this post, and then a real choice about approach.
className, not class
class is a reserved word in JavaScript, so JSX uses the DOM property name:
<footer className="bg-pizza-black text-white-50 mt-5 py-4">It takes a string, so anything that produces a string works — template literals, ternaries,
join:
<div className={`product-card ${featured ? 'product-card-featured' : ''}`}>
<div className={['product-card', featured && 'featured', compact && 'compact']
.filter(Boolean)
.join(' ')}>Once that gets busy, use clsx — one kilobyte, and it is what most codebases end up with:
import clsx from 'clsx';
<div className={clsx('product-card', { featured, compact, 'is-loading': loading })}>Importing stylesheets
Import CSS from JavaScript. Vite handles it; there is nothing to configure:
// Bootstrap's stylesheet first, then our theme, so our overrides win on equal specificity.
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/theme.scss';Import order decides which rule wins on equal specificity, and it is the first thing to check when an override is not taking. Bootstrap first, yours second.
Note that these imports are global wherever you write them. Importing a stylesheet inside a component does not scope it to that component — the rules apply to the whole page, and they stay applied after the component unmounts. Global CSS goes in the entry file, where its scope matches its effect.
Inline styles
The style prop takes an object, not a string:
<div style={{ fontSize: '10rem', lineHeight: 1 }} aria-hidden="true">
🍕
</div>
<Card className="border-0 shadow-sm mx-auto" style={{ maxWidth: '26rem' }}>The double braces are not special syntax — the outer pair escapes into JavaScript, the inner pair is the object.
Keys are camelCased: fontSize, backgroundColor,
borderTopLeftRadius. Values are strings, except for the numeric properties React knows
are unitless — lineHeight, zIndex, opacity,
flexGrow. Everywhere else a bare number gets px appended, so
{{ width: 200 }} means 200px. Custom properties keep their hyphens and their leading
dashes: {{ '--pizza-red': '#d8102a' }}.
Use them sparingly
Inline styles cannot express :hover, :focus, media queries, or anything
pseudo-element. They also cost a new object on every render, which
defeats memoisation on any component you pass one to.
They are right for genuinely dynamic values — a progress bar's width, a computed position — and for one-off constraints not worth a class name, which is what both examples above are. Everything else belongs in a stylesheet.
The approaches
Plain global CSS
One or more stylesheets, imported once, with a naming convention to avoid collisions. This is what
the pizza app does — .pizza-hero, .product-card,
.cart-badge — and for an app of this size it is entirely adequate.
It scales badly on its own: nothing stops two developers picking the same class name, and dead CSS is impossible to find. A convention like BEM helps; tooling helps more.
CSS Modules
Built into Vite. Name a file *.module.css and every class in it is renamed to
something unique at build time:
/* ProductCard.module.css */
.card {
border: 0;
border-radius: 0.75rem;
box-shadow: 0 2px 10px rgb(0 0 0 / 8%);
}
.thumb {
height: 160px;
background: linear-gradient(135deg, #f6b73c 0%, var(--pizza-red) 100%);
}import styles from './ProductCard.module.css';
export function ProductCard({ product }: Props) {
return (
<Card className={styles.card}>
<div className={styles.thumb} aria-hidden="true">🍕</div>
</Card>
);
}styles.card becomes something like _card_1x9k4_3, so collisions are
impossible and a class with no importer is visibly dead. You get scoping with no runtime cost at all
— it is still a plain stylesheet in the output.
The cost is one import per component and slightly noisier JSX. For a design system of shared components it is a good default.
CSS-in-JS
Styles written in JavaScript, next to the component — styled-components, Emotion. The appeal is that styles and markup live together and can use props directly:
const Badge = styled.span<{ $urgent: boolean }>`
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: ${(p) => (p.$urgent ? 'var(--pizza-red)' : '#eee')};
`;It has fallen out of favour, and for reasons worth knowing rather than fashion: the runtime cost is real, it interacts badly with React Server Components, and CSS itself has since gained nesting and custom properties, which removed much of the original motivation. Perfectly fine in an existing codebase; a less obvious choice for a new one.
Utility CSS
Tailwind, and Bootstrap's utility classes, which are the same idea at a smaller scale. You compose styles from single-purpose classes in the markup:
<div className="d-flex justify-content-between align-items-center mt-2">
<span className="fw-bold">
from <span className="text-pizza-red">{formatMoney(cheapest)}</span>
</span>
</div>That is real code from this app — Bootstrap utilities doing almost all the layout, with custom classes reserved for the few things that need them. It is quick to write, needs no naming, and keeps the styling visible where you are reading. The markup gets noisy, which is the trade.
CSS custom properties
Whatever approach you pick, put your palette in custom properties. They are real values the browser holds, so they can be read in DevTools, overridden per subtree, and swapped for a theme — none of which a build-time variable can do.
Bootstrap 5.3 exposes its own design tokens this way, which means retuning it is redefining
variables rather than fighting defaults with !important:
:root {
--pizza-red: #d8102a;
--pizza-red-dark: #a80d21;
--pizza-black: #231f20;
--pizza-cream: #fff8f0;
/* Bootstrap token overrides — every btn-primary, link and focus ring follows these. */
--bs-primary: var(--pizza-red);
--bs-primary-rgb: 216, 16, 42;
--bs-link-color: var(--pizza-red);
--bs-link-hover-color: var(--pizza-red-dark);
--bs-body-font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}That whole theme is under 300 lines because of this. React with Bootstrap goes further into it.
Scoped tokens
Custom properties cascade, so a component can define its own and everything inside it inherits them. The admin charts do this — chart code is written against roles rather than hex values, and the palette is one edit:
.viz-root {
--viz-surface: #ffffff;
--viz-series-1: #d8102a;
--viz-text-primary: #231f20;
--viz-text-secondary: #6c6a68;
--viz-grid: #eceae7;
}
.viz-tooltip {
background: var(--viz-surface);
border: 1px solid var(--viz-grid);
}A note on accessibility
Two things worth building in from the start, because retrofitting them is miserable.
Respect reduced motion. Make transitions opt-in rather than something you undo later:
@media (prefers-reduced-motion: no-preference) {
.product-card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.product-card:hover {
transform: translateY(-4px);
}
}Never remove a focus outline without replacing it. outline: none
makes an interface unusable by keyboard. If the default ring is ugly, style
:focus-visible into something better.
Which to pick
For a small app, global CSS with a naming convention plus a utility framework is fine, and it is what this one uses. For a component library or a large team, CSS Modules or Tailwind. For an existing CSS-in-JS codebase, keep it — migrating styling is rarely the highest-value work available.
Whatever you pick, put the palette in custom properties.