Skip to content
Chapter 18Lesson 1

Utility-first on JSX

Style React components by stacking Tailwind utility classes 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. Each one rendered with browser defaults: black text, plain borders, no styling of your own. This chapter styles them, starting with a decision you make before writing a single style.

A primary button needs padding, a background color, rounded corners, a border, a hover state, a focus ring, and a bit more padding on wider screens. Those styles can live in two places. You can write them as declarations in a .primary-button CSS class and attach it with className="primary-button", or you can stack them directly on the tag as utility classes and skip the separate file.

This course defaults to the second: utility-first for styling that belongs to a component. The component is already a named thing. In React, a <PrimaryButton> has a name, a boundary, and one place it’s defined. A .primary-button class names it again and splits one styling decision across two files you 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, down to one as dense as md:hover:bg-primary/80, and you’ll know the few moments when hand-written CSS is the right call instead. The grammar is small, so we start there.

To see what utility-first earns, style the same thing both ways. Here’s a card header, a flex row with the title on the left, padding, and a bottom border, written first as a named CSS class, then as utilities.

styles.css
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--color-border);
}
card.tsx
<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.

All the named class bought you is the word card-header: a name you invented, meaningless to the browser, that exists only to point from the markup at the declarations, and that you walk through on every edit. A utility class like p-4 skips the indirection and says what the element does. This fits React because your components are already named: a <CardHeader> that also carries a .card-header class names the same thing twice and pays for it with a second file and a sync burden. Utility-first drops the second name, so the component is the unit and the utilities are its style, both in one place. A long string like flex items-center justify-between p-4 border-b border-border reads as a code smell only by the reflex of traditional CSS, but the named-class version held the exact same five declarations; it just hid them behind a name and a file. The long string is those declarations made visible at the point of use, which is the feature.

A utility class is one pre-named, single-purpose CSS declaration: the class is the style it sets, with no lookup or indirection. When you write p-4, you are writing padding: 1rem.

Utility class
CSS it emits
p-4
padding: 1rem;
bg-primary
background-color: var(--color-primary);
rounded-lg
border-radius: var(--radius-lg);
A utility name is its declaration: nothing to look up, no indirection. The middle column is what Tailwind generates for you.

The values aren’t arbitrary. The 4 in p-4, the lg in rounded-lg, and the primary in bg-primary are theme tokens . p-4 doesn’t hardcode 1rem; it resolves through var(--spacing-4), and so do m-4, gap-4, and every other 4, because they all read from one scale the project defines once. Change that entry and every utility using it moves together. The scale already lives in the project’s globals.css; for now you’re a consumer, and growing it is the next lesson’s job.

Because each class sets exactly one declaration, classes compose cleanly. p-4 bg-primary rounded-lg is three independent facts: padding, background, and radius are different properties, so stacking the classes just stacks the declarations, and their order in the string doesn’t matter. Two utilities that do set the same property, say two paddings, follow a resolution rule you’ll meet once you start composing classes conditionally.

Utility families and the naming convention

Section titled “Utility families and the naming convention”

Tailwind has a large set of utility classes, but you don’t learn them as a list. You learn about 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, not a docs lookup.

Here are the families you’ll actually reach for, with a few representatives of each.

Layout
flexgridblockhiddenitems-centerjustify-between
Spacing
p-4px-6m-2gap-4
Sizing
w-fullh-screensize-10
Color
bg-cardtext-foregroundborder-border
Typography
text-smfont-mediumleading-tighttracking-tight
Border & radius
borderborder-2rounded-mddivide-y
Effects
shadow-smopacity-90transitionring-2
Roughly eight families and one naming convention. Recognize the family; let autocomplete fill in the exact name.

Three of these have a modern default worth pinning down now:

  • Spacing is p-* (padding), m-* (margin), and gap-*, all driven by the same scale. Inside a flex or grid container, gap-4 is how you space children apart; prefer it over margins on the children, which collapse and leak in ways gap does not.
  • Sizing has a shorthand: size-10 sets width and height together to the same scale value, instead of w-10 h-10. It’s ideal for square things like icons, avatars, and icon buttons.
  • Color uses role names, not color names: bg-card, text-foreground, border-border. Tailwind also has bg-blue-500, but a raw color hardcodes one specific blue into a component that should instead ask the design system “what’s the card background?” and let the theme answer. Semantic names are what let one component work in both light and dark themes without changes. How that works comes later in this chapter; for now, read bg-card as “the background color the design system assigns to cards.”

The convention tying these 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. So border-b is border-bottom and px-6 is horizontal padding at scale 6. Once that clicks, you guess class names instead of memorizing them.

The Tailwind CSS IntelliSense extension for VS Code makes this ergonomic. Type bg- and you get the full list of background utilities with color swatches inline; hover any class and it shows the CSS it compiles to; misspelled classes get flagged. Install it before you write much Tailwind.

Tailwind CSS IntelliSense
marketplace.visualstudio.com

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 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.

Target
Your output LIVE

That’s the everyday loop: pick the family, name the property, pick the scale value, and stack them on the tag. The rest of the lesson covers 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 background changes on hover, a focus ring appears when focused, padding grows on larger screens. Tailwind expresses every one of these the same way: a prefix on the front of the utility, separated by a colon.

The model is one sentence: a variant is a selector or media-query wrapper around the utility. The utility stays the declaration; the prefix says when it applies. hover:bg-primary compiles to roughly &:hover { background-color: var(--color-primary) }, the background utility gated behind the :hover selector. md:p-6 compiles to @media (min-width: 48rem) { padding: 1.5rem }, the padding utility gated behind a breakpoint . The prefix changes when, not what.

Here is the densest class in this lesson, pulled apart.

breakpoint variant
md:
at the md screen size and up
state variant
hover:
when the element is hovered
utility (the declaration)
bg-primary
set background to the primary color
opacity modifier
/80
at 80% opacity

At md and up, when hovered, set the background to the primary color at 80% opacity.

Every Tailwind class reads as this same grammar: gates on the left, the declaration in the middle, a value modifier on the right.

That shape, [breakpoint][state][utility][modifier], is the grammar every Tailwind class follows. Gates stack on the left, each a selector or media query the declaration has to pass through; the utility sits in the middle; a value modifier can hang off the right.

The gates come in families. This lesson teaches the plain ones, the variants that read a state the element has on its own. Here they are.

Pseudo-class state covers 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 plain focus: for rings: keyboard users get a visible ring without one flashing on every mouse click. Every interactive element in this course gets a visible focus state, and focus-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 form controls track:

  • checked: applies when a checkbox or radio is checked.
  • invalid: applies when an input fails its own validation, such as a required field left empty or a malformed email.

These read the input’s own state. Styling other elements from a form control’s state, such as turning a sibling error message red, belongs to the DOM-state family covered later in this chapter.

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: 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 on every screen, then from md up, px-6 takes over. It does not mean “small padding on medium screens.” You design for the small screen first, then layer overrides for the bigger ones. 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. Pair 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. Recognize it 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 why the color examples above used bg-card and text-foreground. The full dark-mode model comes later in this chapter.

When you stack prefixes, they read left-to-right as nested gates: md:hover:bg-primary is “at md and up, when hovered.” Put the broadest constraint outermost, on the left. Breakpoint and theme tend to go outside, interaction state inside, so the class reads from the widest condition down to the most specific.

Everything above gates on a state the element tracks about itself. But variants can also read state the DOM knows about something else, such as a parent currently hovered, a sibling input that’s invalid, or an attribute like data-state="open" flipped on by a UI library, with no React state or 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.

Target
Your output LIVE

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 */
"
>

The escape-hatch ladder: opacity, arbitrary values, and !important

Section titled “The escape-hatch ladder: opacity, arbitrary values, and !important”

The theme scale covers almost everything you style. When it doesn’t, Tailwind offers a graded set of escape hatches. The skill is reaching for them in order, escalating only as far as the problem requires. Scale first, escape hatch last.

The / opacity modifier. A postfix on a color or ring utility that sets that color’s alpha:

overlay.tsx
<div className="bg-foreground/10" />
<span className="text-primary/80">Subtle</span>
<button className="ring-2 ring-ring/50" />

Use it for translucent overlays and faint borders. Prefer it over hand-written rgba(...) because it reads the alpha off the theme token: bg-primary/80 is your primary color at 80%, so it stays correct when the theme changes, with no hardcoded color to drift.

Arbitrary values, [...]. When no scale token fits, 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]" />

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.

The framing matters more than the syntax: every repeated arbitrary value is a signal the scale should grow. A true one-off, like a hero illustration that needs exactly 37rem and nowhere else, is fine; that’s what the hatch is for. But once you write w-[37rem] in two or three components, the bracket is telling you the value wants a name. The fix isn’t to keep escaping but to grow the scale so the components reference a token, which is the next lesson.

Arbitrary properties, [property:value]. One step further out, for when there’s no utility at all for the property you need:

<div className="[scrollbar-width:none]" />

You’ll rarely need this, since most properties have utilities. Recognize the shape, [property:value] rather than [value], and move on.

CSS variables in utilities. When the value is set at runtime, computed by a script and dropped onto the element as a custom property, wrap the property name in parentheses:

<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. An older bracket form, bg-[var(--card-overlay)], still works and is what you’d use when the bracketed value is more than a bare variable, but the parenthesis form is the default for the simple case. Custom properties in depth come in a later chapter; here, just know the shorthand exists.

The ! important modifier. A trailing ! forces !important:

<p className="text-muted-foreground!" />

The ! goes last, after every variant prefix: hover:bg-primary!, not !hover:bg-primary. This is a genuine last resort, legitimate only for overriding stubborn third-party CSS you can’t edit. In your own components it almost always means something upstream needs fixing.

Utility-first is the default, not dogma. A few real boundaries call for hand-written CSS, and naming them keeps you choosing your tool rather than obeying a rule sheet.

Reach for bespoke CSS at these boundaries:

  • Long-form prose. An article body or rendered Markdown, where an author wrote the paragraphs, headings, and lists and you don’t control each element. You can’t hang utility classes on markup you didn’t write; this is what the typography plugin’s prose class exists for.
  • 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.
  • Deep pseudo-element work. A ::before or ::after doing real work with content and 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: utility-first by default for styling that belongs to a component, bespoke CSS at these named boundaries, chosen deliberately, not by taste or because a class string is getting long.

You’ll see @apply suggested as a middle ground; mostly avoid it. It folds utilities back into a named class (.btn { @apply px-4 py-2 rounded-md; }, then className="btn"), which reintroduces the exact named-class indirection utility-first removed: the invented name, the separate file, the sync burden, now in Tailwind syntax. It’s not the default. Its legitimate uses are narrow, like styling a third-party element you genuinely can’t reach; reaching for it to tidy long class strings undoes the thing you came for.

One more trap trips people up most, because it fails quietly: no error, no warning, just a style that never appears.

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. So the moment you construct a class name from a variable, Tailwind never sees the result and silently generates nothing.

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. No CSS is generated, the element renders with no background, and no error points you at the cause.

The rule that prevents the whole class of bug: never assemble a Tailwind class name from a string. Every class a component might use must appear complete somewhere in your source, usually as a lookup map keyed by the variable, like the safe tab. The raw color names here only illustrate the trap; far more often you’ll pick between semantic classes conditionally, which has a dedicated helper, cn(), coming next lesson. The same rule holds there.

Two more things to watch for:

  • Don’t glue conditional classes with template-literal concatenation, as in className={`base ${isActive ? 'extra' : ''}`}. Beyond the trap above, it produces conflicting duplicate utilities whose winner depends on build order. The next lesson’s cn() is the right tool for conditional and override-able class strings.
  • Install the Tailwind IntelliSense extension if you skipped it earlier. Without it the surface feels harder than it is.

Finally, a debugging reflex you’ll use constantly. When a style isn’t showing, open DevTools, select the element in the Elements panel, and read its literal class attribute; then check Computed for which declarations resolved and which utility set each one. The first question is always whether the class is even in the DOM.

The earlier exercises drilled the syntax. This one drills the senior judgment underneath it: when to reach for a utility, and when bespoke CSS earns its weight. Sort each styling job into its bucket, 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.

Utility classes The default for component-internal styling
Bespoke CSS Earns its weight at the boundaries
A card’s padding and border
A button’s hover background change
An icon-only button’s focus-visible ring
A grid that changes from one column to three at the md breakpoint
The body text of a blog article rendered from Markdown
A three-keyframe loading-spinner animation
Overriding a third-party widget’s stubborn baked-in styles
A decorative ::before quote mark with its own content