Skip to content
Chapter 19Lesson 3

Preflight, the deliberately blank canvas

Preflight, the CSS reset Tailwind loads for free, and why clearing the browser's defaults gives your design system a clean canvas to paint on.

You scaffold a fresh Tailwind app, drop in a chunk of plain, semantic HTML of the kind you’d write on any other day, and the page comes back looking wrong.

<div className="card">
<h1>Upgrade to Pro</h1>
<ul>
<li>Unlimited projects</li>
<li>Priority support</li>
</ul>
<button>Start trial</button>
</div>

The <h1> isn’t a heading. It’s the same size and weight as the list items next to it, body text wearing a heading’s tag. The <ul> has lost its bullets and its indent, so the two features sit flush against the left edge like loose sentences. And the <button> doesn’t look like a button at all: no border, no fill, no native styling, just a run of plain text sitting inline with everything else. You wrote correct markup, and the browser rendered something that looks unstyled in a way it never does anywhere else.

Almost everyone reacts the same way here, and you may have just thought it too: Tailwind broke my HTML.

It didn’t, and nothing broke. The instant you added the @import "tailwindcss" line back in the last chapter, Tailwind quietly ran a small, deliberate reset called Preflight , and Preflight deleted the browser’s default styling on purpose. The big bold heading, the bullets, the native button look: those styles were never yours. They came from the browser, and Tailwind stripped them so you could start from nothing.

This lesson takes you from “broke my HTML” to “blank canvas.” By the end you’ll see that flattened look as a surface you want. You’ll know exactly what got deleted and why, you’ll recognize it on sight in DevTools, and you’ll know the two narrow situations where an experienced engineer deliberately brings some defaults back.

Two earlier lessons left a thread to pick up here. In How the browser picks a winning rule you saw that Preflight lives in @layer base, which is why a bare <h1> has nothing for your text-2xl to fight against. And in What flows down the DOM tree you met Preflight’s one font: inherit rule, the rule that re-opens form controls to your typography. Both were named in passing and left unexplained. This is where they, along with every other reset Preflight ships, get explained in full.

Preflight is the base layer, loaded for free

Section titled “Preflight is the base layer, loaded for free”

Before we list what Preflight strips, it’s worth pinning down what Preflight is and where it lives, because knowing its location turns the whole list from trivia into something you can reason about.

Preflight is a small set of CSS rules Tailwind v4 ships. You never wrote them. You wrote one line, @import "tailwindcss", and that line does more than pull in your utilities. Expanded, here is what it actually loads:

@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css" layer(utilities);

Look at the third line. Preflight is preflight.css, and it loads into layer(base), the same base layer you sorted your own element resets into last lesson. That placement decides everything that follows. Recall the layer gate from How the browser picks a winning rule: a declaration in a later-declared layer beats one in an earlier layer, no matter how specific either selector is, and the gate is checked before specificity even gets a turn. Preflight sits in base, and your utilities sit in utilities, the last layer declared. So Preflight lives in base, your utilities live in utilities, and the layer gate hands your utilities the win every single time.

That is why you never have to fight Preflight, and why you’ll never need !important against it. You don’t out-specify it or reach for any heavier tool. You write the utility, and the layer order, settled before specificity is consulted, gives it the win automatically. Preflight sits structurally beneath everything you write, so by design it cannot win a conflict with one of your utilities.

So why ship a reset at all? Because the browser’s defaults are a liability for a design system. They are decades of accumulated decisions, they differ between browsers, and not one of them is on your scale: a default heading isn’t on your type scale, a default margin isn’t on your spacing scale, and a default button doesn’t read your color tokens. Preflight normalizes all of that away, so that every bit of visual weight on the page comes from a utility you wrote: predictable, tokenized, and identical in every browser. Without a reset, your design system would compete with the browser’s defaults on every element, and Preflight clears the field before that competition can start.

What follows is a catalog of the resets. The goal is to name them, not to walk through every one exhaustively, because you don’t need to recite Preflight’s stylesheet. What you need is to predict the flattened look of a common element and recognize each reset for what it is. So read every item below through one lens: each one is a browser default being subtracted, and in every case the replacement is the same. The styling is now a utility’s job.

The fastest way to feel that subtraction is to watch it happen. The sequence below renders one small fragment of markup, a card with a heading, a short list, a line of text, and a button, and walks it from raw browser defaults to the flat Preflight surface, one reset at a time. Scrub through it and watch each default get deleted. The page isn’t degrading; it is being cleared.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

What the browser ships: decades of defaults, and they differ between browsers.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

Margins and padding zeroed. Spacing is yours now, on your scale.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

Headings inherit body size and weight. Nothing left for text-2xl to override.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

Lists lose their bullets, exactly what you want for nav and option menus.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

font: inherit; color: inherit re-opens the button to your typography. With the border reset gone too, it’s an unadorned run of text, inline with everything else.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

Not broken, just blank. Every bit of visual weight now comes from a utility you write.

Upgrade to Pro

  • Unlimited projects
  • Priority support

Your trial ends in 3 days.

Now you paint. text-2xl font-bold on the heading, space-y-* on the list, a real button: the weight is back, but this time it’s yours and on your scale.

That arc, starting full, clearing to blank, then repainting deliberately, is the lesson in miniature. Here are the rules behind it. These are Preflight’s headline resets in one place, the literal CSS Tailwind ships, with each one tied to what it deletes and which earlier thread it picks up.

*,
::before,
::after {
box-sizing: border-box;
border: 0 solid;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul, menu {
list-style: none;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}

Margins and padding are gone everywhere: every element, plus ::before and ::after. There is no default space above an <h1>, between paragraphs, or around lists, <figure>, or <hr>. Spacing stops being an accidental browser value and becomes yours, declared with gap and p-* and m-* on your --spacing scale. (The course prefers gap to sibling margins, a choice the box-model chapter covers.)

*,
::before,
::after {
box-sizing: border-box;
border: 0 solid;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul, menu {
list-style: none;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}

Two resets in one rule. box-sizing: border-box makes every element measure its padding and border inside its declared width, the sensible model, which the next chapter covers in full. And border: 0 solid matters more than it looks: it pre-sets every border to zero width and solid style. Your border utility only sets the width, so it renders a visible line only because Preflight already set the style to solid. Strip Preflight away and a bare border paints nothing, because a border’s default style is none. No color is pinned here, so a fresh border starts at the current text color, currentColor (the thread from What flows down the DOM tree), until you set an explicit border-* color.

*,
::before,
::after {
box-sizing: border-box;
border: 0 solid;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul, menu {
list-style: none;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}

Headings are flattened, which is the thread from How the browser picks a winning rule. A bare <h1> is set to inherit its size and weight, so it renders at body size and body weight. There is no large default left for text-2xl font-bold to override, because Preflight already neutralized it. A heading’s size and weight are now entirely whatever utility you put on it, nothing more and nothing less.

*,
::before,
::after {
box-sizing: border-box;
border: 0 solid;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul, menu {
list-style: none;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}

Lists are unstyled: no bullets, no numbers, and the margin/padding reset already removed the indent. This can feel like a loss, but look at what a real SaaS UI is made of: nav menus, command palettes, option lists, dropdowns. Almost all of them are a <ul> semantically and not bulleted visually. So a bulletless list is what you want roughly nine times in ten. The tenth case, a genuinely bulleted prose list, is handled by prose, which is coming up.

*,
::before,
::after {
box-sizing: border-box;
border: 0 solid;
margin: 0;
padding: 0;
}
h1, h2, h3, h4, h5, h6 {
font-size: inherit;
font-weight: inherit;
}
ol, ul, menu {
list-style: none;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}

Form controls re-inherit your typography, which is the thread from What flows down the DOM tree. This is the exact rule that re-opens the controls that normally resist inheritance. font: inherit; color: inherit makes a <button> stop wearing its native OS typography and take your body font and color instead, so the controls rejoin the cascade you set up on <body>.

1 / 1

That AnnotatedCode covers the four highest-value resets (margins, box-sizing and borders, headings, and form controls) plus lists. A few more are worth a sentence each, because you’ll see their effects but rarely think about them.

Images and media go block-level and constrained. Preflight sets img, svg, video, … { display: block; vertical-align: middle } and img, video { max-width: 100%; height: auto }. The first removes the mysterious sliver of space under an inline image (the inline baseline gap), and the second stops an image from overflowing its container. Two of the most common image annoyances are gone in one move.

Links lose their underline, and their color inherits. A bare <a> comes out body-colored rather than browser-blue, with no underline. That’s a clean slate you’ll style per design with something like text-primary underline-offset-*, or get for free from a component later.

A few more are real but low-stakes: tables get border-collapse, [hidden] is enforced as display: none, and a placeholder’s color resolves to the current text color at 50% opacity rather than a hardcoded grey. Recognize them if they come up, but don’t memorize them.

There’s one reset people expect to find here and won’t: Preflight does not set cursor: pointer on buttons. Tailwind v4 removed that, so a bare <button> uses the browser’s default cursor: default, which matches native behavior. If a button feels unclickable, the cursor is your job: you add cursor-pointer, or in practice shadcn’s <Button> handles it for you. It is not a Preflight rule, so you won’t find it in @layer base.

Here is the skill to take away from this lesson: when an element comes out flattened, decide correctly whether that’s Preflight doing its job or an actual mistake. Most of the time it’s the reset, and the right response is to add a utility. Occasionally it’s a real bug. The goal is to tell the two apart.

You’re staring at a fresh Tailwind app, nothing painted yet. Which of these symptoms are Preflight doing its job — flattened on purpose, and the fix (if you even want a difference) is to add a utility — rather than an actual bug? Select all that apply.

Your <h1> lands at the exact size and weight of the paragraph sitting under it.
Your <select> shed its chunky native OS look and now reads in your body font.
You stripped the Tailwind import out of your CSS to “clean things up,” and now a border utility draws no line at all.
A heading came out the wrong size, and DevTools shows the class text-2xI never matched anything.

You already have the tool for this: the Styles panel, from the last two lessons. Here you point it at one specific thing to look for.

Select any flattened element, open the Styles panel, and scroll to the bottom. There, beneath every other rule, you’ll find Preflight’s rule sitting in a section labeled @layer base. That label is what you’re looking for. Seeing the rule in the base layer tells you three things at once: it’s a reset, it sits structurally beneath everything, and any utility you add, which lands in @layer utilities above it, will win cleanly. You’re not looking at the cause of a problem. You’re looking at confirmation that the reset is exactly where it should be.

Styles
Computed
Layout
Event Listeners
body main h1.title
Filter :hov .cls
element.style { }
@layer utilities
.text-2xl { globals.css:1
font-size : 1.5rem;
}
.font-bold { globals.css:1
font-weight : 700;
}
@layer base
h1, h2, h3, h4, h5, h6 { preflight.css
font-size : inherit;
font-weight : inherit;
}
1 @layer utilities — your text-2xl sits in the later layer, so it wins. Its declarations aren't struck.
2 @layer base — Preflight, beneath everything. Its font-size: inherit is struck through: the reset did its job, your utility took over.
The Styles panel, read for a selected <h1>: your text-2xl wins in @layer utilities up top, while Preflight's heading rule sits in the dimmed @layer base band below with its font-size: inherit struck through. The reset is doing its job; the utility is your job.

Attach one sentence to what you see and carry it: the reset is doing its job, and the utility is my job. It’s the same habit from the last two lessons: when something looks off, you read where each value came from instead of guessing.

The same view tells you when the problem isn’t Preflight. Suppose your text-2xl really is there in @layer utilities and the heading is still body-sized. Preflight being visible in base is not the bug; it’s behaving exactly as shown. So you trace it the way How the browser picks a winning rule taught: open Computed, expand font-size, and read which rule won and which layer it came from. Maybe an unlayered rule of your own is sitting above utilities and stealing the win. Preflight in base is not the cause, and the trace will point you at what is.

When you bring defaults back, do it on purpose

Section titled “When you bring defaults back, do it on purpose”

Here is the rule of thumb, stated once and then unpacked. An experienced engineer does not strip Preflight to undo the flattening. They lean into the blank canvas and paint with utilities, and in exactly two situations they make a narrow, scoped addition that brings some browser defaults back. Both of those carve-outs are tightly bounded, and any fix outside them is the wrong one.

Carve-out 1: prose for content you don’t author element by element

Section titled “Carve-out 1: prose for content you don’t author element by element”

Consider the case where your app renders a blog post, a changelog, or a help article, written in Markdown or pulled from a CMS. What reaches your component is a blob of HTML: <h2>, <p>, <ul>, <blockquote>, <code>, <a>, all generated, none of it carrying your classes. You can’t put a utility on each element, because you never wrote the elements. And for once you actually want the typographic defaults back, with real heading sizes, bulleted lists, styled links, and sensible spacing, because long-form reading is exactly where those defaults earn their keep.

That’s what the @tailwindcss/typography plugin is for. You install it in your CSS next to the import:

@import "tailwindcss";
@plugin "@tailwindcss/typography";

and then you wrap the rendered content in the prose class:

<article className="prose dark:prose-invert max-w-prose">
{/* rendered Markdown lives here */}
</article>

Here’s the family you’ll meet. prose is the base, prose-sm and prose-lg size it, and prose-invert flips it for dark mode. max-w-prose caps the line length for readability, and max-w-none removes that cap. The key point, though, is that prose is not “undoing Preflight.” It doesn’t strip the reset or override it. It’s a scoped, tokenized typographic system applied to exactly the subtree that needs it, the one place where you legitimately can’t reach the elements one by one. It even themes through @theme tokens like --prose-body and --prose-headings, so it stays on your design system rather than escaping it. You’ll use this later, in the empty-state chapter; here, just recognize it as the right tool for content you don’t own.

Carve-out 2: a scoped @layer base override for a third-party widget

Section titled “Carve-out 2: a scoped @layer base override for a third-party widget”

The second case is when you embed something you don’t control: a payment provider’s iframe host element, an embedded rich-text editor, or a legacy script that injects its own DOM. Such a widget expects browser defaults and visibly breaks without them. You bring the defaults back, but scoped to that widget’s container, and you keep the override inside @layer base so it stays beneath your utilities and doesn’t leak into the rest of the app:

@layer base {
.third-party-widget :where(h1, h2, h3) {
font-size: revert;
font-weight: revert;
}
}

Two details here draw on earlier parts of this chapter. revert, from What flows down the DOM tree, rolls the property back to the user-agent value, which is precisely “give me the browser default again,” exactly the right tool for the job. And the @layer base plus :where() choice is straight from How the browser picks a winning rule. Keeping the override in base means your utilities still win everywhere they apply, and wrapping the selector in :where() holds its specificity at zero so it never starts a specificity conflict. The result is a scoped, polite override that brings defaults back without breaking the system anywhere else.

With the two legitimate moves in hand, the wrong ones are easy to name. Each is a reflex worth catching in yourself before you reach for it.

Don’t strip Preflight globally to “fix” the flattened look. You’d lose box-sizing: border-box, the border-style reset (so your borders would stop rendering, the dependency you just met), and the form-control inheritance, and you’d hand yourself back the browser-divergent defaults you were trying to escape. The flattening you wanted to fix is local, but the cost of stripping the reset is everywhere.

Don’t @apply h1 { … } or write a global h1 { font-size: … } to “restore” headings. That re-introduces an element rule competing with your utilities, the exact anti-pattern How the browser picks a winning rule warned against. If you want heading-sized headings on markup you own, put the utilities on them. If it’s content you don’t own, reach for prose. There’s no third option that involves resurrecting a global element rule.

Don’t reach for !important. There is nothing here to override. Preflight is in base, your utility is in utilities, and the utility already wins the layer gate before importance or specificity is even consulted. An !important here is the warning sign from the cascade lesson with no real problem behind it: you’d be forcing a win you already have.

There’s a genuinely rare third case worth knowing exists, at the level of recognition only: omitting Preflight entirely. That’s legitimate when you ship utilities into an environment that already has its own reset, such as a widget mounted inside a third-party site or a CMS with its own base styles, where running a second reset would collide with the first. Tailwind lets you import its layers selectively to handle that. But for a standalone SaaS app, the kind you’re building toward, Preflight always loads. The how is out of scope; recognizing the case is the point.

Four situations, four correct moves. The skill is reaching for the right lever, and noticing when the right lever is simply to add a utility.

Match each situation to the move an experienced engineer makes. Click an item on the left, then its match on the right. Press Check when done.

A bare <h1> on markup you wrote looks like body text.
Working as intended — drop text-2xl font-bold on it.
A blob of rendered Markdown needs real headings, bulleted lists, and styled links.
Wrap it in the prose class.
An embedded third-party widget breaks without browser defaults.
Add a scoped @layer base override using revert.
A utility isn’t applying, and Preflight is sitting in @layer base.
Not Preflight — trace the cascade to find what’s really winning.

Preflight and the components you’ll actually ship

Section titled “Preflight and the components you’ll actually ship”

Step back and notice something about everything you’ve seen in this lesson: you’ve been styling bare <button>, <input>, and <h1> elements. On a real project you almost never do. shadcn’s <Button>, its <Input>, and your own components are all built on top of the blank canvas Preflight provides. They assume the reset ran, and they add tokenized styling on a surface where nothing competes with them. A <Button> looks identical in every browser because Preflight cleared the browser’s opinion first, and the component then painted its own on the clean surface underneath.

So here’s the idea to close on, and it has the same shape as the last two lessons. Preflight isn’t a quirk to work around. It’s the clean foundation every later styling decision in this course stands on. You don’t fight the blank canvas, you paint on it, with utilities and with components. And the one time you bring defaults back, you do it the way an experienced engineer does: scoped and on purpose, with prose for content you don’t own and a narrow @layer base override for a widget that genuinely needs the defaults, never globally and never with !important.

The thread runs straight into the next lesson. The same @layer and inheritance machinery that makes Preflight predictable is what makes design tokens predictable too: a value set high in the tree, flowing down, and overridable in a subtree. That’s Custom properties and the three-tier token model, where the blank canvas gets the system that paints it.