To add a class to a React component, pass a string to the className prop, as in <div className="plan-card">. For classes that change, build that string from props or state, and use clsx once the conditions pile up.
<div className="plan-card">Team</div>
<button className={clsx("plan-card", { "is-selected": selected })}>
My test bench for this one: React 19.3, TypeScript 7.0.2, clsx 2.1.1 and Vite 8.3 on Windows 11, with every page rendered in Microsoft Edge so the class names you’ll see in the console are real.
In this tutorial, I’ll build a small pricing-plan picker and use it to show you how to add a class to a React component in five steps, from one static class up to classes that react to clicks.
Here’s what we’re building:

Before You Start: A React Plan Picker Sample
You need Node.js and a React + TypeScript project. If you don’t have one, a Vite project takes a minute, and it pairs well with typed function components:
npm create vite@latest plan-picker -- --template react-ts
cd plan-picker
npm install
npm install clsx
npm run dev
Here’s the sample data every step reuses. It’s four plans, in US dollars per month:
| Plan | Price | Flags | Extra class it earns |
|---|---|---|---|
| Starter | $9.00 | none | none |
| Team | $29.00 | popular: true | is-popular |
| Business | $79.00 | none | none |
| Legacy | $19.00 | retired: true | is-retired |
Put the styles in src/App.css. These are the class rules (the full file adds a few layout lines):
.plan-card {
width: 170px; padding: 14px 16px; text-align: left; font: inherit;
background: #fff; border: 1px solid #c8ced6; border-radius: 10px;
cursor: pointer; color: #1f2933;
}
.plan-card .name { display: block; margin-bottom: 6px; font-weight: 600; }
.plan-card .price { display: block; font-size: 22px; font-weight: 700; }
.is-popular { border: 2px solid #f59f00; }
.is-popular::after {
content: "Most popular"; display: block; margin-top: 6px;
font-size: 12px; color: #b35c00;
}
.is-retired { opacity: 0.45; cursor: not-allowed; }
.is-selected {
background: #e7f5ff; border-color: #1c7ed6; box-shadow: 0 0 0 3px #a5d8ff;
}
.compact { width: 130px; padding: 8px 10px; }
Step 1: Add a Static className to the Component
JSX uses className, not class, because class is a reserved word in JavaScript. If the component idea itself is new to you, start with what a React component is.
Replace src/App.tsx with this. The CSS file is imported once at the top, and every card gets the same plan-card class:
import "./App.css";
type Plan = { name: string; price: number };
const plans: Plan[] = [
{ name: "Starter", price: 9 },
{ name: "Team", price: 29 },
{ name: "Business", price: 79 },
];
function PlanCard({ plan }: { plan: Plan }) {
return (
<div className="plan-card">
<span className="name">{plan.name}</span>
<span className="price">${plan.price.toFixed(2)}/mo</span>
</div>
);
}
export default function App() {
return (
<div className="plans">
{plans.map((p) => <PlanCard key={p.name} plan={p} />)}
</div>
);
}

Checkpoint: you should see three white cards with rounded borders. If they’re unstyled, the import "./App.css" line is missing.
Step 2: Combine Multiple CSS Classes in className
Now Team should stand out and Legacy should look retired. The first idea most of us try is a template literal with a ternary and &&, the same tools you’d use for conditional rendering in React:
// inside PlanCard
const classes =
`plan-card ${plan.popular ? "is-popular" : ""} ${plan.retired && "is-retired"}`;
return <div className={classes}>{/* same spans as Step 1 */}</div>;
What the browser got (one line per card):
"plan-card undefined"
"plan-card is-popular undefined"
"plan-card undefined"
"plan-card is-retired"
That’s the trap. A ternary leaves an empty string behind, so you get double spaces. And && returns the left side when it’s falsy, which puts undefined in the class list as a word.
Put the parts in an array, drop the falsy ones with filter(Boolean), and join with one space:
// inside PlanCard
const classes = [
"plan-card",
plan.popular && "is-popular",
plan.retired && "is-retired",
].filter(Boolean).join(" ");
Class names after the fix:
"plan-card"
"plan-card is-popular"
"plan-card"
"plan-card is-retired"

Step 3: Toggle a Class on Click with useState
A class that follows user input comes from state. Keep the picked plan name in useState, compare it in each card, and add is-selected when it matches. The click wiring is plain React event handling.
I switched the card to a <button> here, so it’s keyboard-friendly and disabled works for Legacy:
type CardProps = {
plan: Plan;
selected: boolean;
onSelect: () => void;
};
function PlanCard({ plan, selected, onSelect }: CardProps) {
const classes = [
"plan-card",
plan.popular && "is-popular",
plan.retired && "is-retired",
selected && "is-selected",
].filter(Boolean).join(" ");
return (
<button
type="button"
id={`plan-${plan.name}`}
className={classes}
disabled={plan.retired}
onClick={onSelect}
>
<span className="name">{plan.name}</span>
<span className="price">${plan.price.toFixed(2)}/mo</span>
</button>
);
}
export default function App() {
const [picked, setPicked] = useState("Starter");
return (
<>
<div className="plans">
{plans.map((p) => (
<PlanCard
key={p.name}
plan={p}
selected={picked === p.name}
onSelect={() => setPicked(p.name)}
/>
))}
</div>
<p className="status">Selected plan: {picked}</p>
</>
);
}

Checkpoint: click any active card and the blue ring moves to it. React re-renders, rebuilds each class string, and only the clicked card’s string contains is-selected.
Pro Tip: don’t reach for element.classList.add() in a click handler. React owns className, so the next render quietly overwrites anything you added by hand.
Step 4: Pass a className Prop to a Custom Component
Here’s the part that confuses people. className on a <div> works because it’s a DOM element. On your own <PlanCard /> it’s just a prop, and the component decides what to do with it.
TypeScript makes that visible. I added a small CurrentPlan.tsx that passes className to a card that doesn’t declare it:
type Plan = { name: string; price: number };
function PlanCard({ plan }: { plan: Plan }) {
return <div className="plan-card">{plan.name}</div>;
}
export function CurrentPlan() {
const team: Plan = { name: "Team", price: 29 };
return <PlanCard plan={team} className="compact" />;
}
tsc output:
src/CurrentPlan.tsx(9,32): error TS2322: Type '{ plan: Plan; className: string; }' is not assignable to type 'IntrinsicAttributes & { plan: Plan; }'.
Property 'className' does not exist on type 'IntrinsicAttributes & { plan: Plan; }'.

Plain JavaScript would accept it and silently drop the class. The fix is the same in both: declare className as an optional prop and merge it into the component’s own list:
type CardProps = {
plan: Plan;
selected?: boolean;
onSelect?: () => void;
className?: string;
};
function PlanCard({ plan, selected, onSelect, className }: CardProps) {
const classes = [
"plan-card",
className,
plan.popular && "is-popular",
plan.retired && "is-retired",
selected && "is-selected",
].filter(Boolean).join(" ");
// ...same <button> as Step 3
}
// inside App():
<aside>
<h4>Your current plan</h4>
<PlanCard plan={plans[1]} className="compact" selected />
</aside>
The sidebar card’s class list:
"plan-card compact is-popular is-selected"

Checkpoint: the sidebar card is smaller than the others, and its class list starts with plan-card compact. The parent controls layout and the card still controls its own states.
Step 5: Clean Up the Class List with clsx
The array-and-join pattern works, but it gets noisy. clsx is a tiny package that takes strings, objects and falsy values and returns one clean string. Each object key is a class, kept only when its value is truthy.
Here’s the new PlanCard, plus an effect in App that logs the selected card’s classes. The VS Code shot below has the whole file:
import clsx from "clsx";
function PlanCard({ plan, selected, onSelect, className }: CardProps) {
return (
<button
type="button"
id={`plan-${plan.name}`}
className={clsx("plan-card", className, {
"is-popular": plan.popular,
"is-retired": plan