Utility-first on JSX
Your first lesson on styling React components with Tailwind, stacking utility classes directly on the JSX instead of writing separate CSS.
In the last chapter you chose the right element for every job: <button> for actions, <nav> for navigation, <form> for submissions. Every one of them rendered unstyled, with black text, default borders, and browser defaults all the way down. This chapter paints them, and it starts with a decision you make before you type a single style.
Take one primary button. To ship it, it needs padding so the label isn’t cramped, a background color, rounded corners, a border, a hover state so it responds to the pointer, a visible focus ring so keyboard users can see where they are, and a little more horizontal padding on larger screens. That’s seven distinct style facts about one element, and there are two places they can live. You can invent a class, .primary-button, write those seven facts as CSS declarations in a .css file, and pin the class onto the JSX with className="primary-button". Or you can stack the styles directly on the tag as utility classes and skip the separate file entirely.
This course’s default is the second one: utility-first for styling that belongs to a component. The reason is that the component is already a named thing. In React, a <PrimaryButton> is a unit with a name, a boundary, and a single place it’s defined. Adding a .primary-button CSS class names again what’s already named, and it splits one styling decision across two files you now have to keep in sync. Utilities keep the style on the element, where the structure already is.
By the end of this lesson you’ll read and write any Tailwind class string fluently, including one as dense as md:hover:bg-primary/80, and you’ll know the handful of moments when stepping outside utilities into hand-written CSS is the right call. The grammar is small, so let’s start there.
What utility-first replaces
Section titled “What utility-first replaces”The fastest way to feel why utility-first earns its place is to style the same thing both ways and compare. Here’s a card header, a flex row with the title on the left, some padding, and a bottom border, written first as a named CSS class, then as utilities.
.card-header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid var(--color-border);}<div className="card-header">{/* ... */}</div>Two files, one invented name. card-header describes nothing the browser cares about; it exists only to bridge the markup to the declarations. Every time you edit this element’s style you read the class name, jump to the stylesheet, find the rule, edit it, and jump back.
<div className="flex items-center justify-between p-4 border-b border-border"> {/* ... */}</div>One place, no invented name. The same five declarations, now read straight off the tag. flex items-center justify-between is the layout, p-4 is the padding, border-b border-border is the bottom border. The style and the structure are one decision in one file.
Consider what the named-class version actually bought you. It bought you the word card-header: a name you had to invent, that means nothing to the browser, and that exists for one purpose, to point from the markup at the declarations. To style this element you walk that indirection on every single edit. The utility version deletes both the file and the name. A utility class like p-4 describes what the element does, padding itself by 1rem, instead of naming an identity for it.
The argument underneath all of this is what makes utility-first fit a React stack specifically: your components are already named. A <CardHeader> component that also carries a .card-header class has named the same thing twice, once as a component and once as a class, and it pays for the duplication with a second file and a sync burden, in exchange for nothing. Utility-first drops the second name. The component is the unit, the utilities are its style, and both live in one place.
There’s a natural objection here. The first time you see flex items-center justify-between p-4 border-b border-border on one line, the instinct from years of traditional CSS is that this is a mess: a code smell, a wall of classes that should have been a clean named rule. That reaction is worth taking seriously, because it’s the most common one people have to utility-first. But the alternative to that long string is not “less stuff.” It’s the exact same five declarations, moved one indirection away, behind a name you invented and a file boundary you now maintain. The classes didn’t disappear in the named-class version; they turned into CSS properties you can’t see without opening another file. A long class string is style made visible at the point of use, which is the feature, not a smell.
A utility class is one CSS declaration
Section titled “A utility class is one CSS declaration”Before going broad, settle the one idea the rest of the lesson builds on, because every later piece is a variation of it.
A utility class is one pre-named, single-purpose CSS declaration. That’s the whole definition. The class is the style it sets: there’s no lookup, no indirection, nothing hidden. When you write p-4, you are writing padding: 1rem. The name and the declaration are the same fact in two spellings.
Two things to notice in that table, both of which the next sections build on.
First, the values aren’t arbitrary. The 4 in p-4, the lg in rounded-lg, and the primary in bg-primary are theme tokens : named entries in a scale the project defines once. p-4 doesn’t hardcode 1rem; it resolves through var(--spacing-4) to 1rem, and so do m-4, gap-4, and every other 4 on the scale. Change one entry in the scale and every utility that references it moves together. You won’t define that scale here. It already exists in the project’s globals.css, and for now you’re just a consumer of it; growing it is the next lesson’s job. The point for now is only this: the numbers and names point at a shared design system, which is exactly why they keep the UI consistent.
Second, because each class sets exactly one declaration, the classes compose cleanly. p-4 bg-primary rounded-lg is three independent facts that don’t interfere: padding, background, and radius are different CSS properties, so stacking the classes just stacks the declarations. The order in the string doesn’t matter, because they’re not competing. Two utilities that do set the same property, say two different paddings, is a real situation with a real resolution rule, but you won’t hit it until you start composing classes conditionally, and we handle it then. For now, treat each class as one declaration, and know that they don’t fight unless two of them aim at the same property.
Keep this in mind, one class and one declaration, because families, variants, modifiers, and escape hatches are all transformations layered onto it.
The utility families you write daily
Section titled “The utility families you write daily”Tailwind has a lot of utility classes. The number is genuinely large, and if you try to learn it as a list you’ll bounce off it and conclude the whole thing is unmemorizable trivia. It isn’t, because you don’t learn the list. You learn maybe eight families and one naming convention, and the convention makes the exact class names guessable. A name you half-remember is one autocomplete keystroke away, never a thing you look up in docs.
Here are the families you’ll actually reach for, with a few representatives of each.
A few of these have a detail worth pinning down now, because they’re the modern defaults and you’ll want to reach for them reflexively:
- Spacing is your
p-*(padding),m-*(margin), andgap-*, all driven by the same scale. Inside a flex or grid container,gap-4is how you space children apart; it’s the spacing primitive for layout. Reaching for margins on the children to push them apart is the older habit, and it’s the one to drop, becausegapis cleaner and doesn’t collapse or leak the way sibling margins do. - Sizing has a shorthand worth knowing:
size-10sets width and height together to the same value, instead of writingw-10 h-10. It runs on the same spacing scale asp-*, and it’s ideal for square things like icons, avatars, and icon buttons. - Color is where you’ll see the most deliberate naming in this course. Notice the representatives are
bg-card,text-foreground, andborder-border: role names, not color names. There’s abg-blue-500in Tailwind too, and it works, but reaching for a raw color like that in real application UI is a smell, because it hardcodes a specific blue into a component that should instead ask the design system “what’s the card background?” and let the theme answer. You’re seeing semantic names here on purpose: they’re how this course styles everything, and they’re what makes a single component work in both light and dark themes without changes. How that works comes later in this chapter; for now, readbg-cardas “the background color the design system assigns to cards.”
The naming convention tying all of this together is {property-abbreviation}-{value}: p for padding, bg for background, w for width, text for text color or size, then a dash, then the scale token or value. border-b is border-bottom, and px-6 is horizontal padding at scale 6. Once that clicks, you stop memorizing and start guessing correctly.
The tool that makes this ergonomic in practice is the Tailwind CSS IntelliSense extension for VS Code. With it installed, you type bg- and get the full list of background utilities with color swatches rendered inline, you hover any class and it shows you the exact CSS it compiles to, and it flags class names that don’t exist. It turns “what was that class called again?” into autocomplete, which is the whole reason the surface feels small once you’re set up. Install it before you write much Tailwind.
Autocomplete, the compiled-CSS hover preview, and color swatches in VS Code. The daily instrument that makes the utility surface ergonomic.
Now build one. The exercise below starts with a plain <div> and shows you a target card beside it. Compose the class string to match.
Match the target card using utility classes on the inner div and its paragraph. It needs padding all around, the card background color, rounded corners, a subtle border, a small shadow, and slightly larger medium-weight text for the title. Leave the theme <style> block alone.
That’s every utility-class skill you’ll use on a normal day: pick the family, name the property, pick the scale value, and stack them on the tag. The rest of the lesson is about the marks that hang off these base utilities, the prefixes on the left and the modifiers on the right.
Reading a class string: the prefix-and-colon grammar
Section titled “Reading a class string: the prefix-and-colon grammar”So far every utility applies unconditionally: p-4 is always padding: 1rem. But real UI is conditional. A button’s background changes on hover. A focus ring appears when focused. Padding grows on larger screens. Tailwind expresses every one of these with the same move: a prefix on the front of the utility, separated by a colon.
The mental model is one sentence: a variant is a selector or media-query wrapper around the utility. The utility is still the declaration, and the prefix says when it applies, meaning under which selector or inside which media query. hover:bg-primary compiles to roughly &:hover { background-color: var(--color-primary) }, the same background utility gated behind the :hover selector. md:p-6 compiles to @media (min-width: 48rem) { padding: 1.5rem }, the same padding utility gated behind a breakpoint. The prefix doesn’t change what the utility does; it changes when.
Once you see that, you can decompose any class, no matter how dense. Here is the densest one in this lesson, pulled apart.
At md and up, when hovered, set the background to the primary color at 80% opacity.
That shape, [breakpoint][state][utility][modifier], is not specific to this one class. It’s the grammar every Tailwind class follows. Gates stack on the left, each one a selector or media query the declaration has to pass through; the utility sits in the middle as the actual declaration; and a value modifier can hang off the right. Once the shape is second nature, you never meet a “magic string” again, only a sentence you can parse.
The gates on the left come in families. This lesson teaches the plain ones, the variants that read a state the element has on its own. A second group reads state from elsewhere in the DOM, such as a parent, a sibling, or an attribute; that group gets its own lesson later in this chapter, and we’ll return to it at the end of this section. Here are the plain families.
Pseudo-class state covers the interaction states the browser tracks on the element itself:
hover:applies when the pointer is over the element.focus-visible:applies when the element is focused and the browser judges a focus ring should show, meaning keyboard navigation rather than a mouse click. Reach for this over plainfocus:for focus rings: it gives keyboard users a visible ring without flashing one on every mouse click. Every interactive element in this course gets a visible focus state, andfocus-visible:is how.active:applies while the element is being pressed.disabled:applies when the disabled attribute is set.
A real button uses several at once:
<button className="bg-primary hover:bg-primary/90 active:bg-primary/80 focus-visible:ring-2 disabled:opacity-50"> Save</button>Form state covers the native states that form controls track:
checked:applies when a checkbox or radio is checked.invalid:applies when an input fails its own validation, such as arequiredfield left empty or a malformed email.
These read the input’s own state. There’s a richer way to style other elements based on a form control’s state, such as turning a sibling error message red when the input is invalid, and that belongs to the DOM-state family covered later in this chapter, not here.
Responsive covers the breakpoint prefixes sm:, md:, lg:, xl:, and 2xl:, and they work mobile-first. This is the single most misread rule in Tailwind, so read it slowly: an unprefixed utility is the base that applies at every size, and a prefixed utility applies at that breakpoint and every size above it. Prefixes are min-width gates, not “only at this size.”
<button className="px-3 md:px-6">Continue</button>That reads as px-3 (horizontal padding 3) on every screen by default, then from md up, px-6 takes over. It does not mean “small padding on medium screens.” Base first, then larger breakpoints widen the gate. You design for the small screen, then layer overrides for the bigger ones. Responsive design as a discipline, which breakpoints to use and what to change, comes later in the course; here you just need the grammar.
Accessibility covers variants gated on user and device preferences:
motion-reduce:applies when the user has asked the OS to reduce motion. Build the habit of pairing it with any motion noticeable enough to bother someone:transition-transform motion-reduce:transition-none. This course requires it on visible animation, so start reaching for it now.print:applies styles for the printed page.contrast-more:applies when the user prefers higher contrast.
dark: gates a utility to dark mode. You’ll see it named here as the dark-theme gate, but reaching for dark: on every color utility is not how this course does dark mode. The better approach uses semantic tokens that resolve per theme, which is exactly why the color examples above used bg-card and text-foreground. The full dark-mode model and its wiring come later in this chapter; for now, just recognize dark: when you see it.
When you stack prefixes, they read left-to-right as nested gates: md:hover:bg-primary is “at md and up, when hovered.” One convention is worth absorbing early: put the broadest constraint outermost, on the left. Breakpoint and theme tend to go on the outside and interaction state on the inside, so the class reads from the widest condition down to the most specific.
Before you practice, there’s one natural follow-up question to address. Everything above gates on a state the element tracks about itself. But variants can also read state the DOM already knows about something else and style the element accordingly: a parent currently hovered, a sibling input that’s invalid, or an attribute like data-state="open" flipped on by a UI library. All of that works with no React state and no event handlers involved.
Now put the grammar to work. The exercise gives you a plain button and a target with four behaviors layered on. Reproduce them with prefix-and-colon variants.
Style the button to match the target. It needs: a primary background that darkens on hover, a visible ring on keyboard focus, a disabled state that dims it and ignores the pointer, and horizontal padding that grows from the md breakpoint up. Hover and keyboard-focus your output to check the states — both buttons stay enabled, so the disabled: utilities go in the string but won't fire visually here. Leave the theme <style> block alone.
A quick recall check on the prefixes themselves: match each described behavior to the prefix that produces it.
Pick the prefix that produces each described behavior. The colon and utility are already in place — you supply only the gate on the left. Pick the right option from each dropdown, then press Check.
<button className=" bg-primary ___:bg-primary/90 /* background darkens when the pointer is over it */ ___:ring-2 /* a ring appears only on keyboard focus */ ___:opacity-50 /* dims when the button is disabled */ ___:px-6 /* extra padding from medium screens up */ ___:transition-none /* no animation when the user prefers reduced motion */ ">Opacity, arbitrary values, and the escape hatches
Section titled “Opacity, arbitrary values, and the escape hatches”The theme scale covers the overwhelming majority of what you style. But sometimes you need a value the scale doesn’t have, or a property no utility covers. Tailwind has a graded set of escape hatches for exactly this, and the senior skill is reaching for them in order, escalating only as far as you actually need. Scale first, escape hatch last.
The / opacity modifier. You met it in the anatomy diagram: a postfix on a color or ring utility that sets that color’s alpha. Examples are bg-foreground/10, text-primary/80, and ring-ring/50.
<div className="bg-foreground/10" /><span className="text-primary/80">Subtle</span><button className="ring-2 ring-ring/50" />This is the right tool for translucent overlays and faint borders. The reason to prefer it over hand-writing rgba(...) is that it reads the alpha off the theme token: bg-primary/80 is still your primary color, just at 80%, so it stays correct when the theme changes, with no hardcoded color to drift.
Arbitrary values, [...]. When the scale genuinely has no token that fits, you can drop any CSS value into square brackets and Tailwind builds the utility on the spot:
<div className="w-[37rem]" /><div className="bg-[#1a1a2e]" /><div className="grid grid-cols-[200px_1fr_200px]" />Note the underscores: a class name can’t contain spaces, so inside the brackets an underscore stands in for one. grid-cols-[200px_1fr_200px] is three columns at 200px, 1fr, and 200px.
Here’s the framing that matters more than the syntax: every arbitrary value is a signal. A genuine one-off, such as a hero illustration that happens to need exactly 37rem and nowhere else does, is completely fine; that’s what the escape hatch is for. But if you find yourself writing w-[37rem] in two or three different components, the bracket is telling you that the value wants to be a theme token. The fix isn’t to keep escaping; it’s to grow the scale so the value gets a name and the components reference it, which is the next lesson. Treat a repeated [...] as a prompt, not a habit.
Arbitrary properties, [property:value]. One step further out, this is for when there’s no utility at all for the CSS property you need.
<div className="[scrollbar-width:none]" />You’ll reach for this rarely, since most CSS properties have utilities. Recognize the shape, [property:value] instead of [value], and move on.
CSS variables in utilities. Sometimes the value you want to feed a utility is set at runtime, computed by a script and dropped onto the element as a custom property. Tailwind’s shorthand for that is parentheses around the property name:
<div className="bg-(--card-overlay) w-(--sidebar-width)" />The parentheses auto-wrap the value in var(), so bg-(--card-overlay) becomes background-color: var(--card-overlay). This is the seam between a value JavaScript sets and a utility that consumes it. There’s also an older bracket form, bg-[var(--card-overlay)], which still works and is what you’d use when the bracketed value is more than a bare variable, but the parenthesis form is the current default for the simple case. CSS custom properties in depth are a later-chapter topic; here, just know the shorthand exists.
The ! important modifier. A trailing ! on a utility forces !important:
<p className="text-muted-foreground!" />The ! always goes last, after every variant prefix: hover:bg-primary!, not !hover:bg-primary. This is a genuine last resort. Its one legitimate use is overriding stubborn third-party CSS you don’t control and can’t edit. In your own components it almost always means something upstream needs fixing instead.
The order those five came in is the order to try them in: scale token, then opacity on a token, then an arbitrary value, then an arbitrary property, then !. Each step is a notch further from the design system, so the habit to build is stopping at the earliest one that solves the problem.
Where utilities stop, and the daily traps
Section titled “Where utilities stop, and the daily traps”Utility-first is the default, not a rule to follow blindly. There are real boundaries where hand-written CSS is the better tool, and naming them keeps “utility-first” from hardening into dogma. Experienced engineers choose their tools rather than obey a rule sheet.
Reach for bespoke CSS at these boundaries:
- Long-form prose. An article body, rendered Markdown, or anything where an author wrote paragraphs, headings, and lists you don’t control element by element. You can’t put utility classes on content you didn’t write the markup for, and you wouldn’t want to. This is what the typography plugin’s
proseclass exists for, and it’s a later-in-the-course topic. - Keyframe animations. A multi-step
@keyframes, such as a loading spinner or a complex enter animation, is CSS that genuinely wants to live as CSS. Utility-driven motion shows up later in the course. - Deep pseudo-element work. A
::beforeor::afterdoing real work withcontentand layout, beyond the trivial. - Third-party overrides. Styling markup injected by a library you don’t own, where you can’t reach the elements to put classes on them.
The rule of thumb is this: utility-first by default for styling that belongs to a component, and bespoke CSS at these named boundaries, chosen deliberately, not by taste or because “this string is getting long.”
One thing you’ll see suggested as a middle ground, and should mostly avoid, is @apply. It lets you fold utilities back into a named CSS class: write .btn { @apply px-4 py-2 rounded-md; } and use className="btn". Look closely at what that does, though. It reintroduces the exact named-class indirection utility-first removed, the invented name, the separate file, and the sync burden, just with Tailwind syntax inside. It’s not the default. It has narrow legitimate uses, such as styling a third-party-injected element you genuinely can’t put classes on, but reaching for it to “clean up” long class strings undoes the thing you came for.
One more trap is worth your attention, and it’s the one that trips people up most, because it fails quietly: no error, no warning, just a style that doesn’t appear.
The dynamic-class trap. Tailwind doesn’t watch your app run. At build time it scans your source files as plain text, finds every string that looks like a class name, and generates CSS only for the ones it literally sees. It never executes your code. So the moment you construct a class name from a variable, Tailwind can’t see the result, and it silently generates nothing for it.
const Badge = ({ color }: { color: 'red' | 'green' }) => { return <span className={`bg-${color}-500`}>{color}</span>;};The class never exists. The scanner sees the literal text bg-${color}-500, not bg-red-500 or bg-green-500, because those strings are only assembled when the code runs, which the scanner never does. No CSS is generated, the element renders with no background, and there’s no error to point you at the cause.
const Badge = ({ color }: { color: 'red' | 'green' }) => { const styles = { red: 'bg-red-500', green: 'bg-green-500', }; return <span className={styles[color]}>{color}</span>;};Every class appears literally. bg-red-500 and bg-green-500 are written out in full as source text, so the scanner finds them and generates their CSS. The variable picks which complete class to use; it never builds one.
The rule that prevents the whole class of bug is this: never assemble a Tailwind class name from a string. Every class name a component might use must appear, complete, somewhere in your source for the scanner to find, usually as a lookup map keyed by the variable, like the safe tab above. This example uses raw color names like bg-red-500 only to illustrate the trap; the course uses them sparingly. Picking between semantic classes conditionally is far more common, and it has a dedicated helper, cn(), coming in the next lesson. The same “no string-built class names” rule holds there too.
Two more things to watch for while we’re here:
- Don’t reach for template-literal concatenation to glue conditional classes together, as in
className={`base ${isActive ? 'extra' : ''}`}. Beyond the trap above, it produces conflicting duplicate utilities whose winner depends on build order, which is unpredictable. The next lesson’scn()helper is the correct tool for conditional and override-able class strings; it’s named here and taught there. - Install the Tailwind IntelliSense extension if you skipped past it earlier. Without it the surface feels harder than it is; with it, half the friction disappears.
Finally, here’s a debugging habit you’ll use constantly. When a style isn’t showing up, open DevTools, go to the Elements panel, and select the element. Read the literal class attribute right there in the markup, then check the Computed panel for which declarations actually resolved and which utility set each one. The first question to ask is always whether the class is even in the DOM.
Practice: utility or bespoke CSS?
Section titled “Practice: utility or bespoke CSS?”The exercises so far drilled how to write the syntax. This one drills the decision underneath it: when to reach for utilities versus when bespoke CSS earns its weight. That judgment is the actual senior skill here. Sort each styling job into the bucket it belongs in, using the boundaries from the last section.
Sort each styling job into where it belongs. Utility classes are the default for component-internal styling; bespoke CSS earns its weight at specific boundaries. Drag each item into the bucket it belongs to, then press Check.
focus-visible ringmd breakpoint::before quote mark with its own contentThe whole lesson reduces to one decision and one grammar.
The decision: styling that belongs to a component lives on the component, as utility classes on the JSX tag, because the component is already a named unit and a parallel CSS class just names it again one file away. Long class strings aren’t a smell; they’re the same declarations a named class would hold, made visible at the point of use. Bespoke CSS earns its weight at named boundaries, namely prose, keyframe animations, deep pseudo-elements, and third-party overrides, and @apply is not the default, because it smuggles the named-class indirection back in.
The grammar: a utility class is one CSS declaration with a pre-set name, and everything else is a transformation of that. The numbers and names are theme tokens from a shared scale. A variant prefix gates the declaration behind a selector or media query (hover:, focus-visible:, active:, disabled:, checked:, invalid:, the mobile-first responsive prefixes, motion-reduce:, dark:) and stacks left-to-right, broadest gate outermost. A / modifier sets opacity off the token. When the scale doesn’t fit, escalate in order, from opacity to arbitrary value to arbitrary property to !, and treat every [...] as a signal the scale should grow. And never build a class name from a string, because Tailwind reads your source as text and will silently emit nothing.
You can now read md:hover:bg-primary/80 on sight and know exactly what each segment does. Next, you’ll learn where those theme tokens come from, and how to grow the scale yourself.
External resources
Section titled “External resources”The official utility and variant reference, the lookup the lesson tells you to use instead of memorizing class names.
The VS Code extension: autocomplete, compiled-CSS hover previews, and color swatches.
The official in-browser playground. Paste any class string and watch it render live, no setup. The fastest way to test a class you half-remember.
Tailwind creator Adam Wathan's essay, the dependency-direction argument underneath this lesson's case for putting style on the tag.