# Tailwind CSS

Utility-first CSS with Tailwind v4.3: the basics, responsive layouts, states, your own theme and reusable components.

Multimedia & Creative Technology · 3.1 Extra Webdev · Self-study · version 0.1.0 · 2026-10-14

Tags: css, tailwind, responsive, accessibility

Slides: <https://slides.mctantwerp.be/en/extra-webdev/03-tailwind>

<!-- slide 2 -->

## After this lesson you can

- Read a class list and say what CSS each class produces
- Set up Tailwind v4 in a Vite project, with autocomplete and sorted classes in VS Code
- Build a layout with flex and grid that changes at breakpoints and inside containers
- Style hover, focus, disabled and dark states, and states that come from aria and data attributes
- Add your own colours and fonts with @theme
- Choose between a component, @apply, @utility and an arbitrary value when styles repeat

> **Explanation:** You do not need to remember every class. Nobody does: autocomplete and the docs are open all day, also for people who have used Tailwind for years. What you do need is the system behind the names, so you can guess a class and read somebody else's. That is what the first chapters are for.

<!-- slide 3 -->

## Agenda

1. Utility-first
2. Setting up
3. The basics
4. Layout
5. Responsive design
6. States
7. Your own theme
8. Reuse
9. Motion and accessibility
10. Ecosystem and pitfalls

> **Explanation:** Chapters 1 to 4 are the basics: if you have never used Tailwind, do not skip them. Chapters 5 and 6 make a page responsive and interactive. From chapter 7 on it gets semi-advanced: your own theme, reuse in components, motion and accessibility. The last chapter helps you read Tailwind that somebody else, or an AI, wrote.

<!-- slide 4 -->

## 1. Utility-first

> **Explanation:** Tailwind is the CSS you will meet most in new projects: the Vite and Next.js templates offer it, shadcn/ui is built on it, and Lovable and most AI tools write it by default. Even if you end up preferring plain CSS, you will read and fix a lot of it. In September 2026 the team behind it, Tailwind Labs, joined Shopify. The framework stays open source under the MIT license.

<!-- slide 5 -->

### One class, one job

```html
<button class="rounded-lg bg-sky-700 px-4 py-2 text-white">Save</button>
```

```css
.rounded-lg { border-radius: var(--radius-lg); }
.bg-sky-700 { background-color: var(--color-sky-700); }
.px-4       { padding-inline: calc(var(--spacing) * 4); }
.text-white { color: var(--color-white); }
```

A utility class does one small thing, and its name says which. You do not invent `.save-button` and write its CSS: you combine utilities in the HTML. Docs: [Styling with utility classes](https://tailwindcss.com/docs/styling-with-utility-classes).

> **Explanation:** Look at the variables in the generated CSS: --spacing, --color-sky-700, --radius-lg. That is the theme of Tailwind, and in chapter 7 you change it. The order of the classes in the attribute does not change the result. The Prettier plugin in chapter 2 sorts them anyway, so everybody's code reads the same way.

<!-- slide 6 -->

### The same card twice

#### Your own CSS

```html
<div class="profile">
  <img class="avatar" src="sam.jpg" alt="">
  <p class="name">Sam Serrien</p>
</div>
```

```css
.profile { display: flex; align-items: center; gap: 1rem;
  padding: 1.5rem; border-radius: .75rem;
  box-shadow: 0 10px 15px rgb(0 0 0 / .1); }
.avatar { width: 3rem; height: 3rem; border-radius: 50%; }
.name { font-weight: 600; }
```

#### Tailwind

```html
<div class="flex items-center gap-4
            rounded-xl p-6 shadow-lg">
  <img class="size-12 rounded-full"
       src="sam.jpg" alt="">
  <p class="font-semibold">Sam Serrien</p>
</div>
```

No names to invent and one file to open. Changing this card cannot break a card on another page.

> **Explanation:** Both versions produce the same card. On the left you came up with three class names and wrote them twice, once in the HTML and once in the CSS. On the right everything is in one place, and deleting the card deletes its styles too. With plain CSS, a rule that nothing uses any more tends to stay in the stylesheet forever, because nobody dares to remove it.

<!-- slide 7 -->

### Your first card

Change `p-6` into `p-12`, or add `bg-amber-100` to the first div, and run again.

```html
<div class="flex items-center gap-4 rounded-xl p-6 shadow-lg">
  <div class="size-12 rounded-full bg-sky-500"></div>
  <div>
    <p class="text-xl font-semibold">Sam Serrien</p>
    <p class="text-gray-500">Lecturer, MCT</p>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** This is a sandbox: the code on the left is real HTML, and the script tag at the bottom loads the browser build of Tailwind. Change a class, press Run (or Cmd or Ctrl plus Enter) and the page on the right is built again. Reset puts the original code back. Inside the sandbox one rem is 28 pixels instead of 16, so everything is bigger than in a normal browser tab, but every class does the same thing.

<!-- slide 8 -->

### Love it or hate it

#### Why people love it

- You never invent a class name or switch to a CSS file
- Changing one element cannot break another page
- The CSS stops growing: p-4 is generated once and used everywhere
- Spacing and colours come from one scale, so pages look consistent

#### Why people hate it

- The HTML gets long: a button can carry fifteen classes
- It looks a lot like inline styles, which you were told to avoid
- You learn a second vocabulary on top of CSS
- Without components you copy the same class list everywhere

> **Explanation:** My take: the right column is real, and most of it goes away once you put repeated markup in components (chapter 8) and let your editor sort and complete the classes. What does not go away: Tailwind is CSS with short names. If you do not know what flex-1 or a stacking context does in CSS, Tailwind will not explain it to you. The people who hate it most usually wrote big stylesheets by hand for years and liked doing it, which is a fair position.

<!-- slide 9 -->

### Not inline styles

- **A scale**: `p-4` comes from a spacing scale. `style="padding: 17px"` is a number somebody made up.
- **States**: `hover:bg-sky-800` works. A style attribute has no way to say hover or focus.
- **Screen sizes**: `md:grid-cols-2` is a media query. Inline styles cannot contain one.
- **Easy to override**: Every utility is one class with the same weight. An inline style beats every stylesheet you write.

> **Explanation:** The docs answer this question themselves, because everybody asks it. The difference that matters most in practice is the first one: with inline styles every value is a decision, with Tailwind you pick from a scale, so two developers end up with the same 16 pixels instead of 15 and 17. The second and third are hard limits: a style attribute simply cannot do hover or a media query.

<!-- slide 10 -->

### Your project uses 40 different Tailwind classes. What ends up in the CSS file you deploy?

- A. The whole Tailwind library, a few megabytes
- B. Those 40 classes, a reset and the theme variables
- C. Nothing: a script adds the styles in the browser
- D. Every colour in every shade, just in case

**Answer:** B. Those 40 classes, a reset and the theme variables

Tailwind reads your files at build time and writes CSS for the classes it finds, plus Preflight (its reset) and the variables of your theme. A class you do not use does not exist in the output. The script option exists (the Play CDN), but only for prototypes.

> **Explanation:** If you guessed the first option: that is how early versions worked, with a separate purge step to throw the unused classes away. Since the just-in-time engine (v2.1, the default from v3 on) Tailwind only generates what it finds. You will see how it finds them in the next chapter.

<!-- slide 11 -->

## 2. Setting up

> **Explanation:** Three ways in: Tailwind Play and the script tag need nothing on your laptop, the Vite plugin is what you use in a real project. Laravel and most framework starters already come with the Vite plugin. For a project without a bundler, like a PHP site or a Kirby theme, there is a CLI that watches your files and writes one CSS file.

<!-- slide 12 -->

### Tailwind without installing

- **[Tailwind Play](https://play.tailwindcss.com)**: An editor in the browser with autocomplete, a live preview, device sizes and a share link.
- **[The Play CDN](https://tailwindcss.com/docs/installation/play-cdn)**: One script tag in an HTML file. Good for a prototype or a CodePen, never for a site you deploy.

```html
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Play saves every version under its own url, so you can send a link to a classmate or paste it in a question. It also has a CSS tab where you write @theme, more about that in chapter 7. The script tag builds the CSS in the browser while the page runs, which costs time on every page load and needs JavaScript. That is why the docs say development only. Every sandbox in this deck uses that script tag, which is why each one ends with it.

<!-- slide 13 -->

### A Vite project

```console
$ npm create vite@latest my-site
Pick Vanilla or React, then TypeScript or JavaScript
$ cd my-site
$ npm install tailwindcss @tailwindcss/vite
$ npm run dev
Local: http://localhost:5173/
```

*vite.config.ts*

```ts
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [tailwindcss()],
})
```

*src/style.css*

```css
@import "tailwindcss";
```

Docs: [Installing with Vite](https://tailwindcss.com/docs/installation/using-vite)

> **Explanation:** This is the whole setup in v4: two packages, one line in the Vite config and one line of CSS. No tailwind.config.js, no PostCSS config, no list of files to scan. Vite with React, Vue or Svelte works the same way. For a project without Vite: npm install tailwindcss @tailwindcss/cli, and then npx @tailwindcss/cli -i ./src/input.css -o ./src/output.css --watch.

<!-- slide 14 -->

### How Tailwind finds your classes

Tailwind reads every file in your project as plain text and writes CSS for every word that matches a utility. It skips files in `.gitignore`, `node_modules`, CSS files, images and lock files.

```css
@import "tailwindcss";

/* A UI package in node_modules that uses Tailwind classes */
@source "../node_modules/@acme/ui";

/* An old folder you do not want scanned */
@source not "../src/legacy";
```

Docs: [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files)

> **Explanation:** Because it reads plain text, Tailwind also finds class names in your JavaScript, in a Markdown file or in a comment. That is harmless: at worst you get a few bytes of CSS you do not use. The catch is the other way round, and it is the most common Tailwind bug: a class name that only exists after your code runs, like one glued together from a variable. Chapter 10 shows how to avoid that.

<!-- slide 15 -->

### Autocomplete in VS Code

**[Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss)**: The official extension. Search for it in the Extensions panel, the id is bradlc.vscode-tailwindcss.

- Completes class names as you type, with a colour swatch next to every colour
- Shows the CSS behind a class when you hover it, so `tracking-tight` stops being a mystery
- Underlines two classes that set the same property, like `p-4 p-2`
- Knows your own theme: brand colours and fonts show up in the list

Docs: [Editor setup](https://tailwindcss.com/docs/editor-setup)

> **Explanation:** Install the extension before you write a single class. It needs to find the CSS file with @import 'tailwindcss' to know your theme; when the autocomplete stays empty or does not know your brand colours, that is usually the reason. VS Code may also complain about @theme or @apply in your CSS file: the extension adds a Tailwind CSS language mode that understands them. Cursor and other VS Code forks use the same extension, Zed and the JetBrains editors have it built in.

<!-- slide 16 -->

### Sorted classes

```console
$ npm install -D prettier prettier-plugin-tailwindcss
```

*.prettierrc*

```json
{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindStylesheet": "./src/style.css"
}
```

*before*

```html
<a class="text-white px-4 py-2 bg-sky-700">
```

*after*

```html
<a class="bg-sky-700 px-4 py-2 text-white">
```

Docs: [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss)

> **Explanation:** The plugin sorts in a fixed order: layout first, then spacing, then colours, then everything behind a variant. When everybody on a team uses it, a diff never shows classes that only moved, and you learn where to look for the colour of an element. In v4 tailwindStylesheet points at your main CSS file, so the plugin knows your own utilities too. Add tailwindFunctions with clsx or cn to also sort the strings inside those calls.

<!-- slide 17 -->

### Older projects

#### Tailwind v3

*tailwind.config.js*

```js
module.exports = {
  content: ['./src/**/*.{html,js}'],
  theme: { extend: {} },
}
```

*style.css*

```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

#### Renamed in v4

| v3 | v4 |
| --- | --- |
| `shadow-sm` | `shadow-xs` |
| `shadow` | `shadow-sm` |
| `rounded` | `rounded-sm` |
| `outline-none` | `outline-hidden` |
| `bg-opacity-50` | `bg-black/50` |

Docs: [Upgrade guide](https://tailwindcss.com/docs/upgrade-guide)

> **Explanation:** You will meet v3 in tutorials, in older repos and in code an AI writes, because models learned from years of v3 code. A tailwind.config.js or @tailwind base is a sure sign. In v4 a few names moved one step down, so shadow-sm on a v3 site is shadow-xs in v4: the class still exists, it just looks smaller. The upgrade tool (npx @tailwindcss/upgrade, Node 20 or newer, on a separate branch) rewrites most of it for you, including the config file into @theme.

<!-- slide 18 -->

### A fresh Vite project with @tailwindcss/vite. Where do you list the files Tailwind should scan?

- A. In the content array of tailwind.config.js
- B. In vite.config.ts, as an option of the plugin
- C. Nowhere: it scans the project by itself
- D. In package.json, under "tailwind"

**Answer:** C. Nowhere: it scans the project by itself

v4 detects your files automatically and only skips what is ignored by git, node_modules, CSS and binary files. A content array belongs to v3: you only need @source for a path it would skip on its own. tailwind.config.js is not even read unless you load it with @config.

> **Explanation:** In v3 the content array was the classic reason for 'my class does not work': a new folder that nobody added to the list. In v4 the reasons left are a file in .gitignore, a package in node_modules, or a class name built from a variable.

<!-- slide 19 -->

## 3. The basics

> **Explanation:** This chapter is the vocabulary. Once you know how a class name is put together (what goes before the dash, what goes after it), you can guess most classes before autocomplete shows them. Keep the docs open next to this chapter: the search on tailwindcss.com finds a class by its CSS property too, so typing 'letter spacing' gets you to tracking.

<!-- slide 20 -->

### Reading a class list

| Class | CSS | Read it as |
| --- | --- | --- |
| `p-4 px-4 pt-4` | padding | all sides, x is left and right, t is top |
| `m-4 mx-auto -mt-2` | margin | a minus in front makes it negative |
| `w-64 h-10 size-12` | width, height | size sets both at once |
| `text-lg text-gray-700` | font-size or color | depends on what follows the dash |
| `bg-sky-500` | background-color | colour name and shade |
| `rounded-lg shadow-md` | border-radius, box-shadow | from xs to 2xl and more |

> **Explanation:** Some names are not the CSS property: tracking is letter-spacing, leading is line-height, rounded is border-radius. Those come from typography and design tools. text- does two jobs, size and colour, and Tailwind knows which from what follows. When you are not sure, hover the class with IntelliSense installed and you see the CSS.

<!-- slide 21 -->

### The spacing scale

```css
--spacing: 0.25rem;  /* 4px, in the default theme */

.p-4  { padding: calc(var(--spacing) * 4); }  /* 16px */
.mt-6 { margin-top: calc(var(--spacing) * 6); }  /* 24px */
```

- Every whole number works, and halves too: `p-13`, `gap-1.5`
- The same scale drives padding, margin, gap, width, height and inset
- Fractions for widths: `w-1/2`, `w-2/3`. And `p-px` is exactly 1px

Docs: [Padding](https://tailwindcss.com/docs/padding), [Margin](https://tailwindcss.com/docs/margin)

> **Explanation:** Because the scale is one variable, any whole number works: p-13 or mt-17 exist even though nobody listed them. In v3 only the numbers in the config existed, so older code and AI answers sometimes use p-[52px] where p-13 would do. The scale is also the reason Tailwind designs look tidy: every distance is a multiple of 4 pixels. Change --spacing in your theme and every padding, margin and gap in the project changes with it.

<!-- slide 22 -->

### Padding, margin, gap

Change `gap-4` into `gap-12`, then the `p-4` of the grey box into `p-1`. Which space moves?

```html
<div class="flex gap-4 bg-gray-200 p-4">
  <div class="bg-sky-700 p-4 text-white">p-4</div>
  <div class="bg-sky-700 px-8 py-1 text-white">px-8</div>
  <div class="w-24 bg-sky-700 text-white">w-24</div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Padding is space inside an element, behind its background. Margin is space outside. Gap is space between the children of a flex or grid container, and it is what you want nine times out of ten: it never adds space before the first or after the last child, and it does not collapse like vertical margins do. Tailwind code that uses margins between list items is often older code, before gap worked in flexbox in every browser.

<!-- slide 23 -->

### Colours

50

100

200

300

400

500

600

700

800

900

950

- One name for every colour utility: `bg-sky-700`, `text-sky-700`, `border-sky-700`
- A slash sets the opacity: `bg-black/50` is black at 50%
- 50 is the lightest, 950 the darkest

Docs: [Colors](https://tailwindcss.com/docs/colors)

> **Explanation:** The default palette has 26 colour families since v4.2 (mauve, olive, mist and taupe joined in February 2026), each from 50 to 950, plus black and white. They are defined in oklch, a colour space where the same lightness number looks equally light across hues, so sky-500 and rose-500 feel like the same step. Pick one grey family (slate, gray, zinc, neutral or stone) per project and stick to it: mixing them looks off without anybody knowing why.

<!-- slide 24 -->

### Typography

```html
<h1 class="text-4xl font-bold tracking-tight text-balance">
<p class="text-lg/8 text-gray-600">
<code class="font-mono text-sm">
```

| Class | Sets |
| --- | --- |
| `text-xs … text-9xl` | font size, with a line height that fits it |
| `font-medium font-bold` | font weight |
| `leading-7 tracking-tight` | line height and letter spacing |
| `font-sans font-serif font-mono` | font family |

Docs: [Font size](https://tailwindcss.com/docs/font-size)

> **Explanation:** text-base is 1rem, 16px in a browser with default settings; the scale runs from text-xs (12px) to text-9xl (128px). Every size comes with a line height that suits it, and text-lg/8 overrides that line height in the same class. text-balance spreads the words of a heading evenly over its lines, so you do not get one lonely word on the last line. A custom font still needs to be loaded (a link to Google Fonts or an @font-face); in chapter 7 you give it a utility name.

<!-- slide 25 -->

### Borders, radius and shadows

```html
<div class="rounded-2xl border border-gray-200 shadow-md">
<img class="rounded-full ring-4 ring-white">
<input class="rounded-md border-2 border-sky-700">
```

- `border` is 1px in the current text colour, so add a colour like `border-gray-200`
- Radius from `rounded-xs` to `rounded-4xl`, and `rounded-full` for circles and pills
- Shadows from `shadow-2xs` to `shadow-2xl`; `ring-2` draws a line around the edge

Docs: [Border radius](https://tailwindcss.com/docs/border-radius), [Box shadow](https://tailwindcss.com/docs/box-shadow)

> **Explanation:** One change from v3 trips up a lot of people: a plain border used to be light grey, in v4 it takes the current text colour. So border on a paragraph of black text gives a black line. Always add a colour. The ring utilities draw a line with box-shadow, so it does not take up space and does not shift the layout, which makes them popular for focus and selected states.

<!-- slide 26 -->

### Make it a button

Style the button and the input with what you know so far. The Styled preset shows one way.

```html
<input placeholder="you@example.com">

<button>Subscribe</button>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

*Unstyled*

```html
<input placeholder="you@example.com">

<button>Subscribe</button>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

*Styled*

```html
<input placeholder="you@example.com"
  class="rounded-md border border-gray-300 px-3 py-2">

<button class="mt-4 rounded-lg bg-sky-700 px-5 py-2
  font-semibold text-white shadow-md">
  Subscribe
</button>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Run the unstyled version first: the button looks like plain text and the input has no border. That is Preflight, the reset Tailwind adds before your classes. It removes the default look of buttons, headings, lists and form fields, so an element only looks like what its classes say. That feels annoying for a day and then saves you from fighting browser defaults. The Styled preset is one possible answer, not the only one.

<!-- slide 27 -->

### One element has `text-sm` and `text-sky-700`. What happens?

- A. The last one in the attribute wins
- B. Small text in sky blue
- C. Tailwind gives a build error
- D. Only the colour works, size needs font-sm

**Answer:** B. Small text in sky blue

text- does two jobs: followed by a size it sets font-size, followed by a colour it sets color. Those are two different properties, so both apply. A real conflict is two classes for the same property, like p-4 and p-2, and then the order in the attribute does not decide either (chapter 10).

> **Explanation:** The same goes for border-2 (a width) against border-gray-200 (a colour), and for shadow-lg against shadow-sky-500/20. Tailwind looks at what follows the prefix: a size from the scale, a colour from the palette. That is also why two classes that look alike can both stay on one element.

<!-- slide 28 -->

## 4. Layout

> **Explanation:** Layout in Tailwind is flexbox and grid, with the same names as in CSS. If you know justify-content and align-items, you already know justify-between and items-center. If those still feel shaky, play Flexbox Froggy and Grid Garden once more: Tailwind does not make flexbox easier, it makes it shorter.

<!-- slide 29 -->

### Flexbox

```html
<header class="flex items-center justify-between gap-6">
  <a class="font-bold">Studio</a>
  <nav class="flex gap-4">…</nav>
</header>
```

| Class | CSS |
| --- | --- |
| `flex flex-col flex-wrap` | display: flex, flex-direction, flex-wrap |
| `justify-between justify-center` | justify-content, along the axis |
| `items-center items-start` | align-items, across the axis |
| `flex-1 shrink-0` | take the space left, never shrink |

Docs: [Flex](https://tailwindcss.com/docs/flex), [Justify content](https://tailwindcss.com/docs/justify-content)

> **Explanation:** justify works along the main axis (a row by default), items across it. flex-col turns the axis, so justify and items swap what they do, exactly like in plain CSS. flex-1 makes a child take the space that is left, shrink-0 stops an icon or an avatar from getting squeezed when the text next to it is long. The classic header is the code at the top: logo left, menu right, everything centred vertically.

<!-- slide 30 -->

### Grid

```html
<div class="grid grid-cols-3 gap-6">
  <article class="col-span-2">…</article>
  <aside>…</aside>
</div>

<div class="grid grid-cols-[240px_1fr]">…</div>
<main class="mx-auto max-w-5xl px-6">…</main>
```

`col-span-2` stretches an item over two columns, `place-items-center` centres in both directions, and `max-w-5xl mx-auto` keeps a page from getting too wide.

Docs: [Grid template columns](https://tailwindcss.com/docs/grid-template-columns), [Max width](https://tailwindcss.com/docs/max-width)

> **Explanation:** grid-cols-3 makes three equal columns, and that covers most card grids. For anything uneven, write the template yourself in square brackets, with underscores where CSS has spaces: grid-cols-[240px_1fr] is a fixed sidebar and a main column that takes the rest. For the width of a whole page the usual pattern is mx-auto max-w-5xl px-6: centred, never wider than 64rem, with some air on a phone. Tailwind also has a container class, but it does not centre itself and has no padding, so you end up writing the same three classes anyway.

<!-- slide 31 -->

### Flex and grid, live

Change `justify-between` into `justify-end`. Then load Grid and turn `grid-cols-3` into `grid-cols-2`.

```html
<header class="flex items-center justify-between gap-6">
  <a class="text-xl font-bold">Studio</a>
  <nav class="flex gap-4">
    <a>Work</a> <a>About</a>
  </nav>
</header>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

*Flex*

```html
<header class="flex items-center justify-between gap-6">
  <a class="text-xl font-bold">Studio</a>
  <nav class="flex gap-4">
    <a>Work</a> <a>About</a>
  </nav>
</header>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

*Grid*

```html
<div class="grid grid-cols-3 gap-4">
  <div class="h-20 bg-sky-200"></div>
  <div class="h-20 bg-sky-300"></div>
  <div class="h-20 bg-sky-400"></div>
  <div class="h-20 bg-sky-500"></div>
  <div class="h-20 bg-sky-600"></div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Two presets, two layouts. In the Flex one, also remove items-center and give the logo a bigger font size: the links now sit at the top instead of in the middle. In the Grid one, add col-span-2 to the first box and watch the others flow around it. Grid fills row by row by default; grid-flow-col changes that.

<!-- slide 32 -->

### Positioning

```html
<div class="relative">
  <img class="aspect-video w-full rounded-xl object-cover" src="…" alt="…">
  <span class="absolute top-3 right-3 rounded-full bg-white px-3 py-1">New</span>
</div>

<header class="sticky top-0 z-10 bg-white/80 backdrop-blur">…</header>
```

`absolute` places the badge against the nearest `relative` parent. `sticky top-0` keeps the header in view while the page scrolls.

Docs: [Position](https://tailwindcss.com/docs/position)

> **Explanation:** absolute positions an element against the nearest parent that is positioned itself, so forgetting relative on the card is the classic bug: the badge flies to the corner of the page. inset-0 is top, right, bottom and left at 0, handy for an overlay that covers its parent. sticky top-0 keeps a header in view while you scroll, as long as no parent has overflow hidden. Use z-10 or z-50 sparingly; a z-index war is a sign the layout needs another look.

<!-- slide 33 -->

### Pick the class

**1. The logo on the left of a header, the menu on the right**

- items-between
- **justify-between** ✓
- space-x-auto

justify-content: space-between on a flex container pushes the first and last child to the edges. items- is the other axis, and the other two do not exist.

**2. A form centred on the page, never wider than 32rem**

- text-center w-lg
- justify-center
- **mx-auto max-w-lg** ✓

max-w-lg caps the width at 32rem and mx-auto splits the space left over on both sides. text-center only centres the text inside, justify-center needs a flex parent.

**3. One rem of space between the cards of a grid, not around them**

- **gap-4** ✓
- m-4 on every card
- p-4 on the grid

gap only adds space between items. Margins add space on the outside edges too, and padding on the grid puts space around all the cards instead of between them.

**4. A badge in the top right corner of a card**

- float-right on the badge
- fixed top-2 right-2 on the badge
- **relative on the card, absolute top-2 right-2 on the badge** ✓

absolute positions against the nearest positioned parent, which relative creates. fixed sticks it to the screen, so every badge ends up in the same corner of the window.

**5. A 240px sidebar next to a column that takes the rest**

- **grid grid-cols-[240px_1fr]** ✓
- grid grid-cols-2
- flex w-240

An arbitrary grid template with an underscore for the space. grid-cols-2 makes two equal halves, and w-240 would be 240 steps of the spacing scale: 960px.

> **Explanation:** If one of these went wrong, go back to the slide it comes from: Flexbox, Grid or Positioning. The wrong options are not random: some are v3 names, some are what people guess when they think in plain CSS, some do something else entirely. Learning to tell them apart is half of reading Tailwind.

<!-- slide 34 -->

### Task: A profile card

In Tailwind Play. Done when the card looks finished, has no custom CSS, and you have its share link. Stuck? Reading a class list, Flexbox and Positioning have the classes, the docs search finds the rest.

1. Open play.tailwindcss.com and start from an empty page
2. Build a card with a round avatar, your name, a line about you and two buttons
3. Put the avatar next to the text with flex, the buttons in a row under it
4. Add a "Available for work" badge in a corner with relative and absolute
5. Keep every spacing on the scale: no square brackets
6. Save, copy the share link and keep it for the next assignments

> **Explanation:** Start in Tailwind Play with the card from the slide Your first card and build from there. Look up every class you do not know in the docs rather than asking an AI to write the card: the point is that you learn the names. For the avatar, any square image works, or use a coloured circle with your initials in it. Save often: every save in Play is a new url.

<!-- slide 35 -->

## 5. Responsive design

> **Explanation:** In year 1 you wrote media queries in a separate block at the bottom of your CSS. In Tailwind the media query lives on the element, as a prefix. That makes it easy to see how one element changes across screen sizes, and easy to forget one. Test every page at 375 pixels wide in the devtools; that is a common phone width.

<!-- slide 36 -->

### Mobile first

| Variant | From | CSS |
| --- | --- | --- |
| `sm:` | 40rem, 640px | `@media (width >= 40rem)` |
| `md:` | 48rem, 768px | `@media (width >= 48rem)` |
| `lg:` | 64rem, 1024px | `@media (width >= 64rem)` |
| `xl:` | 80rem, 1280px | `@media (width >= 80rem)` |
| `2xl:` | 96rem, 1536px | `@media (width >= 96rem)` |

> [!WARNING]
> A class without a prefix applies on every screen, a phone included. `sm:text-center` centres from 640px up, never on a phone.

> **Explanation:** This is the one thing almost every beginner gets wrong: sm: does not mean 'on a small screen', it means 'from the sm breakpoint up'. Read md:flex as 'from 768 pixels on, flex'. The breakpoints are in rem, so a user who sets a bigger default font size in the browser gets the tablet layout sooner, which is what you want.

<!-- slide 37 -->

### A layout that changes

```html
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<nav class="hidden md:flex">
<button class="md:hidden">Menu</button>
<h1 class="text-3xl md:text-5xl">
<p class="max-md:text-center">
```

One column on a phone, two from `md`, three from `lg`. The menu button only exists below `md`, the nav only from `md` up.

Docs: [Responsive design](https://tailwindcss.com/docs/responsive-design)

> **Explanation:** Start every element with its phone version, unprefixed, and add a variant at the width where the design starts to look wrong. You rarely need all five breakpoints: md and lg cover most sites. max-md: applies only below 768 pixels, and md:max-lg: only between md and lg. Use those for the odd exception, not as your main way of working, or you end up writing desktop first with extra steps.

<!-- slide 38 -->

### Which breakpoints apply

Read the width, predict the columns and colours, then change `lg:` into `md:` and run again.

```html
<p class="mb-4">This page is <b id="w"></b>px wide.</p>

<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
  <div class="h-16 bg-sky-500 md:bg-pink-500"></div>
  <div class="h-16 bg-sky-500 md:bg-pink-500"></div>
  <div class="h-16 bg-sky-500 md:bg-pink-500"></div>
  <div class="h-16 bg-sky-500 md:bg-pink-500"></div>
</div>

<script>w.textContent = innerWidth</script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** The page panel of the sandbox is a narrow screen of its own, and the first line tells you how wide. Before you run, decide which colour each box gets and how many columns you see. Then change lg:grid-cols-4 into md:grid-cols-4, or md:bg-pink-500 into xl:bg-pink-500. Breakpoints in a media query use the default 16px rem, so sm is 640 pixels here too.

<!-- slide 39 -->

### Container queries

```html
<div class="@container">
  <article class="flex flex-col gap-4 @md:flex-row">…</article>
</div>

<main class="@container/main">
  <div class="@lg/main:grid-cols-3">…</div>
</main>
```

`@container` marks the parent. `@md:flex-row` applies when that parent is at least 28rem wide, whatever the screen does. A name after the slash targets one container in particular.

Docs: [Container queries](https://tailwindcss.com/docs/responsive-design#container-queries)

> **Explanation:** A breakpoint asks how wide the screen is. A card in a narrow sidebar on a big screen gets the wide layout, even though it has no room for it. A container query asks the parent instead, so the same card works in the sidebar and in the main column. They are built into v4, no plugin needed. The container sizes run from @3xs (16rem) to @7xl (80rem), and @max-md: works like max-md:.

<!-- slide 40 -->

### Resize the container

Drag the bottom right corner of the dashed box to the left. The card stacks once the box gets narrower than @sm.

```html
<div class="@container resize-x overflow-auto
            border-2 border-dashed border-gray-400 p-4">
  <div class="flex flex-col gap-4 @sm:flex-row">
    <div class="h-24 shrink-0 bg-sky-500 @sm:w-40"></div>
    <p>A card that follows its container.</p>
  </div>
</div>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** resize-x with overflow-auto gives the dashed box a handle in its bottom right corner. The page stays the same width the whole time; only the box changes, and the card follows the box. In the sandbox one rem is 28 pixels, so @sm (24rem) switches at 672 pixels here; in a normal page it is 384.

<!-- slide 41 -->

### A paragraph has `text-center sm:text-left`. How is it aligned on a phone that is 375px wide?

- A. Left: sm is for small screens
- B. Centred
- C. Left, then centred from 640px
- D. It depends on the browser

**Answer:** B. Centred

Unprefixed classes apply everywhere, and sm: only adds something from 640px up. A 375px phone never reaches sm, so the text stays centred. From 640px on it is left-aligned. That is the mobile-first way to write it: phone first, changes for bigger screens after.

> **Explanation:** If this went wrong, read Mobile first once more. The trick that helps: say the prefix out loud as 'from'. text-center, and from sm on, text-left.

<!-- slide 42 -->

## 6. States

> **Explanation:** States are what makes a page feel alive: a link that reacts, a field that says it is wrong, a menu that opens. Tailwind uses the same prefix idea as breakpoints for all of them. Where plain CSS needs a selector like .card:hover .title, Tailwind puts the state on the element that changes.

<!-- slide 43 -->

### hover, focus and active

```html
<a class="text-sky-700 hover:underline">
<button class="bg-sky-700 hover:bg-sky-800 active:bg-sky-900">
<button class="disabled:cursor-not-allowed disabled:opacity-50" disabled>
<input class="border-gray-300 focus:border-sky-700">
<a class="md:hover:underline">
```

A variant is a prefix with a colon: the class only applies in that state. You can stack them, like a breakpoint and a state together.

Docs: [Hover, focus and other states](https://tailwindcss.com/docs/hover-focus-and-other-states)

> **Explanation:** Since v4 hover: only applies on devices that can actually hover, so a tap on a phone no longer leaves a button stuck in its hover colour. Variants stack from left to right, md:hover:underline reads as 'from md, on hover'. disabled: works on buttons and form fields with the disabled attribute; add cursor-not-allowed so a mouse user sees why nothing happens. Buttons get the default cursor in v4, so add cursor-pointer when you want the hand.

<!-- slide 44 -->

### focus or focus-visible

```html
<button class="rounded-lg bg-sky-700 px-4 py-2 text-white
               focus-visible:outline-4 focus-visible:outline-offset-2
               focus-visible:outline-pink-500">
```

- `focus:` applies on every focus, a mouse click included
- `focus-visible:` applies when an indicator helps, like reaching it with Tab
- `outline-hidden` only together with a `focus-visible:` style of your own

**[Accessibility](https://slides.mctantwerp.be/en/extra-webdev/07-accessibility)**: Focus, contrast and keyboard use beyond the classes.

> **Explanation:** Keyboard users need to see where they are, so removing the outline without a replacement makes your site unusable for them. focus: also fires when you click a button with the mouse, which is why designers ask to remove the ring and developers then remove it for everybody. focus-visible: is the answer: the ring shows for the keyboard, not for the mouse. On text fields browsers treat both as visible, because you need to see where you type.

<!-- slide 45 -->

### Click or Tab

Click the button, then press Tab to reach it. Type a word without @ in the field and leave it.

```html
<button class="rounded-lg bg-sky-700 px-4 py-2 text-white
  hover:bg-sky-800 active:scale-95
  focus-visible:outline-4 focus-visible:outline-pink-500">
  Save
</button>

<input type="email" placeholder="you@example.com"
  class="mt-6 block rounded-lg border-2 border-gray-300 px-3 py-2
  focus:border-sky-700 focus:outline-hidden user-invalid:border-red-600">

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Click the button with the mouse first: no pink outline. Then click in the empty space of the page and press Tab: the outline appears, because now it helps. For the field, type a word without an @ and click outside it. The red border uses user-invalid:, which waits until the user has typed something, unlike invalid:, which turns an empty required field red before anybody touched it.

<!-- slide 46 -->

### group and peer

```html
<a class="group rounded-xl p-6 hover:bg-sky-50">
  <h3 class="group-hover:text-sky-700">Read the case</h3>
  <span class="transition group-hover:translate-x-1">→</span>
</a>

<input type="email" class="peer">
<p class="hidden peer-user-invalid:block">That is not an email address</p>
```

`group` on a parent lets children react to its state. `peer` on an element lets the siblings after it react.

> **Explanation:** group-hover: answers 'style this child when its parent is hovered', the thing you used to write as .card:hover .title. peer only looks at siblings that come before the element, because CSS can only select forward (the ~ combinator). When you nest groups, name them: group/card and group-hover/card: tell Tailwind which parent you mean. There is also in-focus: and friends, which react to any parent without needing the group class.

<!-- slide 47 -->

### Lists and children

```html
<ul class="divide-y divide-gray-200">
  <li class="py-3 first:pt-0 last:pb-0">…</li>
</ul>
<tr class="odd:bg-white even:bg-gray-50">
<label class="rounded-lg border has-checked:border-sky-700 has-checked:bg-sky-50">
<ul class="*:rounded-full *:bg-sky-50 *:px-3">
```

`first:`, `last:`, `odd:` and `even:` style one item of a loop without an extra class. `has-checked:` styles a parent on the state of what is inside.

> **Explanation:** divide-y draws a line between children, never above the first or under the last, which is what you want in a list nine times out of ten. has-checked: is the CSS :has() selector: the label reacts to the checkbox inside it, without JavaScript. The *: variant styles every direct child from the parent, handy for content you do not control, but a class on a child cannot override it, so use it sparingly.

<!-- slide 48 -->

### aria-\* and data-\*

```html
<button aria-expanded="false" class="group">
  Details <span class="group-aria-expanded:rotate-180">▾</span>
</button>

<a aria-current="page" class="aria-[current=page]:font-bold">Work</a>
<div data-state="open" class="data-[state=open]:bg-white">…</div>
```

Your script sets one attribute, screen readers hear it, and the style follows. Built-in: `aria-expanded:`, `aria-checked:`, `aria-selected:` and more. Brackets match a value.

> **Explanation:** This is a good habit that comes for free: your JavaScript changes one attribute, the screen reader reads it, and the CSS follows it. No separate is-open class that can get out of sync with what assistive tech hears. shadcn/ui and Radix put data-state='open' or 'closed' on their components, so you will see data-[state=open]: a lot in generated code. data-active: (without brackets) checks that the attribute exists.

<!-- slide 49 -->

### An accordion

Run, then click the question. The script only changes aria-expanded, the arrow follows.

```html
<button id="q" aria-expanded="false" class="group flex w-full
  justify-between rounded-lg bg-gray-100 px-4 py-3">
  What is utility-first?
  <span class="transition group-aria-expanded:rotate-180">▾</span>
</button>
<p id="a" hidden class="px-4 py-3">Small classes, one job each.</p>
<script>
  q.onclick = () => {
    a.hidden = !a.hidden;
    q.setAttribute('aria-expanded', !a.hidden);
  };
</script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Look at the script: it never touches a class. It flips the hidden attribute and aria-expanded, and Tailwind does the rest. The arrow turns because of group-aria-expanded:rotate-180, and transition makes it turn instead of jump. For a real accordion the details and summary elements do even more for free, and the open: variant styles them.

<!-- slide 50 -->

### The error message sits above the field and has `hidden peer-invalid:block`, the field has `peer`. Why does the message never show?

- A. peer only works on checkboxes
- B. peer only looks at siblings that come before the element
- C. invalid: needs JavaScript
- D. hidden always wins from block

**Answer:** B. peer only looks at siblings that come before the element

peer-\* compiles to the CSS sibling selector ~, which only selects elements after the peer. With the message above the input, it is not a later sibling. invalid works on any form field with a constraint, like type=email, and hidden does not win: both are one class, and block is generated later.

> **Explanation:** Same rule as in plain CSS: the ~ selector only looks forward. If the design needs the message above the field, you can still keep the input first in the HTML and move the message up with flex-col-reverse or order-first, or use has-[input:user-invalid]: on a parent instead.

<!-- slide 51 -->

### Task: A responsive pricing section

In a Vite project with Tailwind. Done when it works at 375px and at 1280px wide, and Tab shows a visible focus on every button. Stuck? Mobile first, A layout that changes and focus or focus-visible.

1. Create a Vite project and add Tailwind with the Vite plugin
2. Build three plan cards: name, price, four features, a button
3. Stack the cards on a phone, three side by side from md
4. Make the middle plan stand out with a ring and a badge
5. Give the buttons hover, active and focus-visible styles
6. Check the text contrast of every button: white on sky-700 or darker

> **Explanation:** Use the Vite setup from chapter 2; the Vanilla template is enough, no React needed. Check at 375 and 1280 pixels in the device toolbar of the devtools. For the focus test, unplug your mouse in your head: Tab through the page and check that you always see where you are. The highlighted plan can use ring-2 and a scale-105 from md up.

<!-- slide 52 -->

## 7. Your own theme

> **Explanation:** A site that only uses the default palette looks like a Tailwind site, and people notice. Your brand colours, your fonts and your spacing go in the theme. In v4 that theme is CSS, not a JavaScript config, so it lives in the same file as your @import and shows up as CSS variables you can use anywhere.

<!-- slide 53 -->

### Your theme in CSS

*src/style.css*

```css
@import "tailwindcss";

@theme {
  --color-brand-100: oklch(0.95 0.03 25);
  --color-brand-500: oklch(0.68 0.19 25);
  --color-brand-700: oklch(0.5 0.17 25);
  --font-display: "Fraunces", serif;
}
```

Now `bg-brand-500`, `text-brand-700`, `border-brand-100` and `font-display` exist, and `var(--color-brand-500)` works in any CSS you write.

Docs: [Theme variables](https://tailwindcss.com/docs/theme)

> **Explanation:** The name decides what you get: a variable that starts with --color- becomes a colour for every colour utility, --font- becomes a font-family utility. Define a few shades (100, 500, 700) instead of a single brand colour, because you will need a light background and a dark text version soon enough. Theme variables have to sit at the top level of your CSS, not inside a selector or a media query. The fonts in the example still need to be loaded, @theme only gives them a name.

<!-- slide 54 -->

### Namespaces

| Variable | Creates |
| --- | --- |
| `--color-*` | every colour utility: `bg-`, `text-`, `border-`, `ring-` … |
| `--font-*` | font families: `font-display` |
| `--text-*` | font sizes: `text-hero` |
| `--breakpoint-*` | responsive variants: `3xl:` |
| `--spacing` | the step of every spacing and sizing utility |
| `--radius-*` | corners: `rounded-card` |

```css
@theme { --color-*: initial; --color-ink: #1a1a1a; --color-brand: #ff645a; }
```

> **Explanation:** Overriding an existing variable changes the default: --breakpoint-sm: 30rem moves sm. Setting a whole namespace to initial removes the defaults, so --color-*: initial followed by your own colours leaves only your palette, and a stray bg-red-500 simply does nothing. That is a good way to keep a team on brand. When a theme variable points at another variable (common with shadcn), use @theme inline.

<!-- slide 55 -->

### A theme in the sandbox

Change `--color-brand` to another colour and run again. Every utility that uses brand follows.

```html
<style type="text/tailwindcss">
  @theme {
    --color-brand: #ff645a;
    --font-display: Georgia, serif;
  }
</style>

<h1 class="font-display text-5xl text-brand">Portfolio</h1>
<p class="mt-4 border-l-4 border-brand pl-4">One variable, every utility.</p>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** The browser build reads a style tag with type text/tailwindcss, so you can write @theme in it. In a real project that goes in your CSS file. Notice that one variable gives you text-brand, border-brand and bg-brand all at once. Add --color-brand-light and use it as a background behind the paragraph.

<!-- slide 56 -->

### Arbitrary values

```html
<div class="top-[117px] w-[37rem]">
<div class="bg-[#bada55] grid-cols-[1fr_500px_2fr]">
<div class="bg-(--hero-bg)">              <!-- var(--hero-bg) -->
<div class="[mask-type:luminance]">       <!-- any CSS property -->
```

> [!WARNING]
> Fine once. A value that comes back belongs in `@theme`, where it gets a name and stays the same everywhere.

Docs: [Using arbitrary values](https://tailwindcss.com/docs/adding-custom-styles#using-arbitrary-values)

> **Explanation:** Square brackets are the escape hatch: any value, and it still works with hover:, md: and the rest. They are fine for a one-off, like the exact offset of a decorative shape. A design full of brackets is a design full of magic numbers, though, and you see that a lot in code exported from Figma or written by an AI. Rule of thumb: the third time you type the same bracket value, it belongs in @theme. Use an underscore where the CSS value has a space.

<!-- slide 57 -->

### Dark mode

```html
<body class="bg-white text-gray-900 dark:bg-gray-950 dark:text-gray-100">
  <div class="border border-gray-200 dark:border-gray-800">…</div>
</body>
```

`dark:` applies when the operating system is set to dark (`prefers-color-scheme`). A toggle on your site needs one line of CSS, on the next slide.

Docs: [Dark mode](https://tailwindcss.com/docs/dark-mode)

> **Explanation:** By default dark: follows the setting of the operating system. That costs no JavaScript and is a fine choice for a portfolio. The downside is visible in the code: every coloured element needs a dark twin, and the class lists get long. Bigger projects define semantic colours instead, like --color-surface and --color-ink, and change the value of those variables in dark mode. Then one bg-surface works in both. That is exactly what shadcn does with bg-background.

<!-- slide 58 -->

### A dark mode toggle

Run, then press Toggle. The style tag turns dark: into a class on the html element.

```html
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

<div class="rounded-xl bg-white p-6 shadow-lg dark:bg-gray-900">
  <p class="text-xl font-semibold dark:text-white">Night shift</p>
  <button id="t" class="mt-4 rounded-lg bg-sky-700 px-4 py-2 text-white">Toggle</button>
</div>

<script>
  const html = document.documentElement;
  t.onclick = () => html.classList.toggle('dark');
</script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** @custom-variant redefines dark: so it looks for a dark class on a parent instead of the system setting. The docs add a small script for the head of your page: it reads localStorage.theme, falls back to the system setting, and sets the class before the page paints, so there is no white flash. Store the choice in localStorage when the user clicks, and remove it when they pick 'system' again.

<!-- slide 59 -->

### You put `--color-brand-500: #ff645a;` in a `:root` rule instead of `@theme`. Does `bg-brand-500` work?

- A. Yes, Tailwind reads every CSS variable
- B. No, only @theme variables create utilities
- C. Only after a restart of Vite
- D. Only in dark mode

**Answer:** B. No, only @theme variables create utilities

Only variables inside @theme tell Tailwind which utilities to generate. A variable in :root is a plain CSS variable: bg-brand-500 is not generated, but you can still use it with bg-(--color-brand-500). Restarting changes nothing.

> **Explanation:** The difference matters for shadcn and Lovable projects too: their colours live in :root as plain variables, and @theme inline maps them to utility names. When a utility does nothing, check first whether its variable is in @theme.

<!-- slide 60 -->

## 8. Reuse

> **Explanation:** This is where opinions about Tailwind split. The official advice is clear and I agree with it: repetition in Tailwind is solved by your framework (a component, a partial, a loop), not by moving classes back into CSS. There are exceptions, and the next slides say when.

<!-- slide 61 -->

### Repetition is a component

*Button.jsx*

```jsx
const styles = 'rounded-lg bg-sky-700 px-4 py-2 font-semibold text-white hover:bg-sky-800'

export function Button(props) {
  return <button className={styles} {...props} />
}
```

```jsx
<Button onClick={save}>Save</Button>
{projects.map((p) => <Card key={p.id} project={p} />)}
```

Docs: [Managing duplication](https://tailwindcss.com/docs/styling-with-utility-classes#managing-duplication)

> **Explanation:** A card that shows up twelve times on a page is usually written once, inside a map or a foreach. A button that shows up in twelve files becomes a component, so changing its padding is one edit. A handful of similar elements in one file? Multi-cursor editing (Alt or Option and click in VS Code) is often faster than any abstraction. The same works in Vue, Svelte, Astro, Blade or Twig: a component or a partial.

<!-- slide 62 -->

### @apply, and when it is fine

```css
/* HTML from a date picker you cannot change */
.datepicker-day {
  @apply rounded-md px-2 py-1 hover:bg-sky-100;
}
```

Fine

- Markup you do not write: plugins, CMS output, widgets
- One small class in a site without components

Not fine

- A .card, .card-title, .card-body system: that is a component
- Making the HTML look shorter

Docs: [@apply](https://tailwindcss.com/docs/functions-and-directives#apply-directive)

> **Explanation:** @apply copies the CSS of utilities into a rule of your own, with your theme values. It is fine for markup you cannot add classes to: a date picker from npm, the HTML a plugin writes, a CMS editor. It is also fine for a single .btn in a plain PHP site where a partial for a button feels heavy. What goes wrong is rebuilding Bootstrap with it: .card, .card-header, .card-title, and you are back to inventing names and keeping two files in sync. In a Vue or Svelte style block, @apply needs @reference to your main CSS first.

<!-- slide 63 -->

### Your own utilities

```css
@utility content-auto {
  content-visibility: auto;
}

@layer components {
  .card { border-radius: var(--radius-xl); padding: --spacing(6); }
}
```

```html
<section class="md:content-auto">
<div class="card rounded-none">   <!-- the utility wins -->
```

Docs: [Adding custom utilities](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-utilities)

> **Explanation:** @utility is for a CSS property Tailwind has no class for, or a combination you use as one thing. Because it lands in the utilities layer, every variant works on it: hover:, md:, dark:. A class in @layer components is the other option: it sits below the utilities, so a utility on the same element always wins. That makes it a good place for a base style you want to tweak per element, like the card below with rounded-none.

<!-- slide 64 -->

### Conditional classes

```jsx
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'

export const cn = (...inputs) => twMerge(clsx(inputs))

<button className={cn('px-4 py-2', active && 'bg-sky-700 text-white', className)} />
```

- `clsx` adds a class only when its condition is true
- `twMerge` keeps the last of two conflicting classes: `px-4 px-2` becomes `px-2`

[clsx](https://github.com/lukeed/clsx), [tailwind-merge](https://github.com/dcastil/tailwind-merge), [cn](https://github.com/shadcn-ui/cn)

> **Explanation:** clsx joins strings and skips anything false, so active && 'bg-sky-700' disappears when active is false. twMerge solves a different problem: a component with px-4 that gets className='px-2' from outside ends up with both, and then the stylesheet order decides, not you. twMerge drops the earlier one. Together they are the cn() helper you find in lib/utils of every shadcn project. New shadcn projects import cn from the cn package, which does the same in one dependency.

<!-- slide 65 -->

### Which way to reuse

**1. The same button with the same twelve classes shows up in fourteen React files**

- **A Button component** ✓
- A .btn class with @apply

In a project with components the button becomes one. Its markup, its classes and its props live in one place, and changing the padding is one edit.

**2. Twenty project cards, rendered from an array with map()**

- Extract the classes into a variable
- **Nothing: the class list is written once** ✓

A loop already removes the repetition. The classes exist once in the source, however many cards end up on the page.

**3. A WordPress plugin outputs a form with its own classes, and you cannot change its HTML**

- **@apply on the plugin classes** ✓
- Arbitrary values in the template

You cannot add classes to HTML you do not control. @apply lets you style the classes the plugin uses with your own theme values.

**4. Your brand orange is bg-[#ff873c] in eight places**

- Keep the brackets, it works
- **Add --color-brand to @theme** ✓

A repeated value needs a name. In @theme it becomes bg-brand, text-brand and border-brand, and changing the shade is one line.

**5. A Card component should accept a className that overrides its padding**

- Concatenate the strings
- **Merge them with cn()** ✓

Concatenating leaves p-6 and p-2 on the same element, and the stylesheet order decides. cn() with tailwind-merge removes the p-6.

**6. You need text-wrap: balance on headings, and want it from md up**

- A custom utility with @utility
- **text-balance with md:** ✓

Check the docs before writing your own: text-balance exists, and md:text-balance works like any variant. @utility is for what Tailwind does not have.

> **Explanation:** There is rarely one right answer in real projects, but there is a usual one, and that is what this drill asks for. If you got the WordPress one wrong: when you cannot touch the HTML, you cannot add classes, so @apply is the only way to use your theme there. The loop one is the case people overthink most.

<!-- slide 66 -->

### Task: Your theme, your button

In your Vite project, with React. Done when the page uses your own colours and font, dark mode switches with a button, and every button on the page is the same component. Stuck? Your theme in CSS, A dark mode toggle and Conditional classes.

1. Add a brand colour in three shades and a display font to @theme
2. Replace every default colour on the page with your own
3. Add a dark mode toggle with @custom-variant and localStorage
4. Build a Button component with a variant prop: primary or outline
5. Map each variant to full class names, and merge className with cn()

> **Explanation:** Start from the pricing section of the previous assignment and switch the Vite project to React if it is not yet (npm create vite@latest with the React template, then copy your markup into JSX, class becomes className). For the toggle, the script on the dark mode slides of the docs is enough; do not install a library for it. If a variant class does not show up, check that the full class name is written somewhere in your code.

<!-- slide 67 -->

## 9. Motion and accessibility

> **Explanation:** Motion makes an interface feel responsive, and too much of it makes some people feel sick. Accessibility is not a separate chapter of a project, it is a handful of habits: real labels, visible focus, enough contrast. Tailwind has a class for each of those habits, so there is no excuse to skip them.

<!-- slide 68 -->

### Transitions

```html
<a class="transition hover:-translate-y-1 hover:shadow-xl">
<button class="transition-colors duration-300 ease-out hover:bg-sky-800">
<img class="transition duration-500 group-hover:scale-105">
```

- `transition` on the element, the change behind a variant
- `duration-*`, `ease-*` and `delay-*` tune it
- Transforms like `scale-105` never push other elements away

Docs: [Transition property](https://tailwindcss.com/docs/transition-property)

> **Explanation:** Put transition on the element itself, not behind hover:, or the animation only runs one way. The plain transition class covers colours, opacity, shadows and transforms at 150ms. Keep durations short for things the user caused (150 to 300ms); longer feels sluggish. translate and scale do not move other elements around, which is why a hover lift uses -translate-y-1 and not a margin.

<!-- slide 69 -->

### Animations and less motion

```css
@theme {
  --animate-wiggle: wiggle 1s ease-in-out infinite;
  @keyframes wiggle {
    0%, 100% { transform: rotate(-3deg); }
    50% { transform: rotate(3deg); }
  }
}
```

```html
<svg class="animate-spin motion-reduce:animate-none">
<span class="motion-safe:animate-wiggle">👋</span>
```

Docs: [Animation](https://tailwindcss.com/docs/animation)

> **Explanation:** The four built-in animations cover loading states: spin for a spinner, ping for a notification dot, pulse for a skeleton, bounce for a 'scroll down' arrow. Your own animation goes in @theme as --animate-name, with its @keyframes in the same block. Some people get dizzy or nauseous from motion and turn on 'Reduce motion' in their OS. motion-reduce: and motion-safe: listen to that setting; animate only what helps, and only with motion-safe: when it is decoration.

<!-- slide 70 -->

### Hover and wiggle

Hover the card. Then turn on Reduce motion in your OS settings and run again.

```html
<style type="text/tailwindcss">
  @theme {
    --animate-wiggle: wiggle 1s ease-in-out infinite;
    @keyframes wiggle { 50% { transform: rotate(15deg); } }
  }
</style>

<div class="rounded-xl p-6 shadow-md transition hover:shadow-xl
  motion-safe:hover:-translate-y-2">
  <span class="inline-block motion-safe:animate-wiggle">👋</span> Hello
</div>

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
```

> **Explanation:** Turn on Reduce motion and run again: the hand stops waving and the card no longer lifts. On a Mac it is in System Settings, Accessibility, Display. On Windows in Settings, Accessibility, Visual effects, Animation effects (turn it off). The shadow still changes, because that is not motion, and the hover still gives feedback.

<!-- slide 71 -->

### Accessibility basics

```html
<button class="rounded-full p-2 hover:bg-gray-100">
  <svg aria-hidden="true">…</svg>
  <span class="sr-only">Close menu</span>
</button>
```

`sr-only` hides text from the screen, not from a screen reader. `hidden` hides it from everybody.

| On white | Contrast |
| --- | --- |
| `text-gray-400` | 2.6 : 1, fails |
| `text-gray-500` | 4.8 : 1, passes |
| `text-sky-600` | 4.0 : 1, fails |
| `text-sky-700` | 5.9 : 1, passes |

Rules: [WCAG contrast (minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)

> **Explanation:** hidden means display: none, so a screen reader skips it too; sr-only keeps the text for screen readers and hides it from the screen. Every icon button needs a label like that. For contrast, WCAG asks 4.5:1 for normal text. The numbers on the right are calculated from the oklch values of Tailwind v4.3. Notice that white on sky-600 just fails, which is why the buttons in this deck use sky-700. The contrast-more: variant lets you strengthen borders and text for people who ask their OS for more contrast.

<!-- slide 72 -->

### A close button only contains an X icon. How do you give it a name for screen readers?

- A. <span class="hidden">Close</span> inside the button
- B. <span class="sr-only">Close</span> inside the button
- C. Nothing, the icon is clear enough
- D. A title attribute on the svg

**Answer:** B. <span class="sr-only">Close</span> inside the button

sr-only keeps the text in the page for assistive tech and only hides it visually. hidden is display: none, which removes it for screen readers as well, so the button stays nameless. An icon is clear to you, not to a screen reader, which only hears 'button'.

> **Explanation:** aria-label on the button is the other good answer, and in React it is often the cleaner one. What matters is that the button has a name a screen reader can say. A title attribute alone is not reliable: many screen readers ignore it, and touch screens never show it.

<!-- slide 73 -->

## 10. Ecosystem and pitfalls

> **Explanation:** The last chapter is about the code around Tailwind: the official plugins, the component libraries you will see in projects, and the Tailwind that AI tools generate. It ends with the mistakes that cost the most time, so you recognise them in your own code and in code you inherit.

<!-- slide 74 -->

### The typography plugin

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

```html
<article class="prose lg:prose-lg dark:prose-invert">
  <!-- HTML from Markdown or a CMS -->
</article>
```

`prose` styles every heading, paragraph, list and link inside, for content you do not write the HTML of.

[Typography plugin](https://github.com/tailwindlabs/tailwindcss-typography), [Forms plugin](https://github.com/tailwindlabs/tailwindcss-forms)

> **Explanation:** Preflight strips the default look from h1, p and ul, which is great for UI and terrible for an article or a blog post coming out of Markdown or a CMS. prose gives that content good defaults in one class: headings, spacing, lists, links, code and quotes. prose-lg makes it bigger, dark:prose-invert flips it for dark mode, and prose-a:text-brand-700 changes one element type. The forms plugin does something similar for inputs, selects and checkboxes: a neutral look that is easy to restyle.

<!-- slide 75 -->

### Component libraries

- **[shadcn/ui](https://ui.shadcn.com)**: Components copied into your project, built on Tailwind and Radix. You own the code.
- **[daisyUI](https://daisyui.com)**: A plugin with component classes like btn and card, and themes.
- **[Headless UI](https://headlessui.com)**: Menus, dialogs and tabs with keyboard support and no styling. You add the classes.

> **Explanation:** shadcn/ui is the one you will meet most, also in every Lovable project: the components are copied into your project with a CLI, so you own and edit the code. daisyUI goes the other way and adds component classes like btn and card, which feels closer to Bootstrap. Headless UI gives behaviour (menus, dialogs, keyboard handling) without any style, and you add the Tailwind classes. Pick one per project; mixing them gives you three ways to make a button.

<!-- slide 76 -->

### Reading generated code

```tsx
<button
  className={cn(
    'inline-flex h-9 items-center gap-2 rounded-md px-3 text-sm',
    'bg-background text-muted-foreground hover:bg-accent',
    'data-[state=open]:bg-accent disabled:opacity-50',
    className,
  )}
/>
```

**[Lovable](https://slides.mctantwerp.be/en/extra-webdev/11-lovable)**: A whole app generated in Tailwind and shadcn, and what to check before you trust it.

> **Explanation:** This is typical shadcn code, and Lovable writes the same. bg-background and text-muted-foreground are semantic colours: variables in index.css that change in dark mode, mapped to utilities with @theme inline. data-[state=open]: comes from Radix. cn() merges the className from outside. Read a long list in groups: layout, spacing, colour, then the states at the end. Prettier already sorts them that way.

<!-- slide 77 -->

### Class names you build

*never*

```jsx
<button className={`bg-${color}-600`}>
```

*always*

```jsx
const colours = {
  blue: 'bg-blue-600 hover:bg-blue-500',
  red: 'bg-red-600 hover:bg-red-500',
}

<button className={colours[color]}>
```

Tailwind reads your files as text before your code runs. `bg-red-600` never appears as a whole word, so it is never generated.

Docs: [Dynamic class names](https://tailwindcss.com/docs/detecting-classes-in-source-files#dynamic-class-names)

> **Explanation:** This is the bug that costs the most time, because it works on your machine for a while: as long as some other file happens to contain bg-red-600, the class exists. It also works in the Play CDN and in this sandbox, because the browser build looks at the finished page. After npm run build it is gone. The fix maps every option to a full class name, which also lets you pick different shades per colour.

<!-- slide 78 -->

### Two classes, one property

```html
<div class="p-4 p-2">          <!-- which one? not the last one -->
<div class="grid flex">         <!-- display: grid, flex is earlier in the CSS -->
```

- The order in the stylesheet decides, not the order in the attribute
- Never put two classes for the same property on one element
- Overrides from outside a component go through `cn()`
- `bg-red-500!` forces it, as a last resort against CSS you cannot change

Docs: [Managing style conflicts](https://tailwindcss.com/docs/styling-with-utility-classes#managing-style-conflicts)

> **Explanation:** Utilities all have the same specificity, so the one that comes later in the generated stylesheet wins, and you do not control that order. IntelliSense underlines the pair; listen to it. In a component, merge with cn(). The exclamation mark at the end (bg-red-500!) makes a class !important; keep it for fighting CSS you cannot change, like a third-party widget. When a class list passes twenty classes, ask whether part of it is a component, and remove what does nothing, like items-center on an element without flex.

<!-- slide 79 -->

### A Badge uses `` `bg-${color}-600` ``. It works in Tailwind Play, but after `npm run build` the badges have no colour. Why?

- A. The build minifies the template literal
- B. Tailwind never saw bg-red-600 as a whole word in your files
- C. Template literals are not allowed in className
- D. Play uses Tailwind v3

**Answer:** B. Tailwind never saw bg-red-600 as a whole word in your files

The build generates CSS from the text of your files, before any code runs, and bg-${color}-600 is not a class. The browser build in Play looks at the finished page instead, where the class is complete, which is why it worked there. Map each option to a full class name.

> **Explanation:** If you have a list of colours that comes from a CMS or a database and cannot write them all out, @source inline() in your CSS forces Tailwind to generate them: @source inline('bg-{red,blue,green}-600'). Use that as an exception; a map of full class names is easier to read.

## Glossary

- **Utility class**: A small class that sets one or two CSS properties and is named after what it does, like p-4 or text-center. Utility-first means you build a design by combining those in the HTML instead of writing a class per component. (slide 5)
- **Play CDN**: The browser build of Tailwind, loaded with one script tag. It generates the CSS while the page runs: fine for a prototype, never for a site you deploy. (slide 12)
- **@tailwindcss/vite**: The Vite plugin of Tailwind v4. Together with @import "tailwindcss" in your CSS it is the whole setup: no tailwind.config.js and no PostCSS config. (slide 13)
- **Automatic content detection**: Tailwind v4 reads every file in your project as plain text and generates CSS for every token that matches a utility. It skips what is in .gitignore, node_modules, CSS files and binaries. @source adds paths it would skip. (slide 14)
- **Preflight**: The reset Tailwind puts in front of your utilities. It removes margins, heading sizes, list bullets and button styling, so an element only looks like what its classes say. (slide 26)
- **Spacing scale**: One variable, --spacing (0.25rem by default), that every padding, margin, gap, width and height utility multiplies. p-4 is four steps: 1rem, or 16px. (slide 21)
- **Opacity modifier**: A slash and a number after a colour utility, like bg-black/50, that sets the alpha of that colour. It replaced the bg-opacity utilities of v3. (slide 23)
- **Variant**: A prefix with a colon, like hover:, md: or dark:, that makes a utility apply only in that state, from that screen size or in that mode. Variants can be stacked: md:hover:underline. (slide 43)
- **Mobile-first**: Unprefixed utilities apply on every screen, a breakpoint variant like md: applies from that width up. You style the phone first and add what changes on bigger screens. (slide 36)
- **Container query**: A style that depends on the width of a parent element instead of the screen. In Tailwind you mark the parent with @container and use variants like @md: on the children. (slide 39)
- **focus-visible**: The state of an element that has focus in a way where the browser thinks an indicator helps, like reaching it with Tab. A mouse click on a button does not trigger it, a keyboard does. (slide 44)
- **group and peer**: Classes that let one element react to the state of another. group-hover: styles a child when its parent with group is hovered, peer-invalid: styles an element when an earlier sibling with peer is invalid. (slide 46)
- **@theme**: The block in your CSS where you define design tokens as variables, like --color-brand-500 or --font-display. Every variable in a known namespace creates utilities, and stays available as a CSS variable. (slide 53)
- **Arbitrary value**: A value in square brackets that is not in the theme, like w-\[37px\] or bg-\[#bada55\]. Handy once; a value that keeps coming back belongs in @theme. (slide 56)
- **@apply**: A directive that copies the CSS of utilities into your own CSS rule. Useful for markup you cannot add classes to; a poor replacement for components. (slide 62)
- **@utility**: A directive that adds your own utility class to Tailwind, so it works with every variant (hover:, md:, dark:) and lands in the utilities layer. (slide 63)
- **tailwind-merge**: A library that removes conflicting Tailwind classes from a list, keeping the last one: px-4 and px-2 become px-2. Combined with clsx it forms the cn() helper of shadcn projects. (slide 64)
- **sr-only**: A utility that hides an element visually but keeps it for screen readers. Use it for the label of an icon button. hidden hides an element from everybody. (slide 71)
- **prose**: The class of the official typography plugin. It styles headings, paragraphs, lists and links inside content you do not write the HTML for, like Markdown or CMS output. (slide 74)

## Links and sources

### In the deck

- [Styling with utility classes](https://tailwindcss.com/docs/styling-with-utility-classes) (slide 5): Why not inline styles, managing duplication and conflicts
- [Tailwind Play](https://play.tailwindcss.com) (slide 12)
- [Play CDN](https://tailwindcss.com/docs/installation/play-cdn) (slide 12): @tailwindcss/browser, development only
- [Installing with Vite](https://tailwindcss.com/docs/installation/using-vite) (slide 13)
- [Detecting classes in source files](https://tailwindcss.com/docs/detecting-classes-in-source-files) (slide 14): Dynamic class names, @source
- [Tailwind CSS IntelliSense](https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss) (slide 15)
- [Editor setup](https://tailwindcss.com/docs/editor-setup) (slide 15)
- [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) (slide 16): tailwindStylesheet and tailwindFunctions options
- [Upgrade guide](https://tailwindcss.com/docs/upgrade-guide) (slide 17): Renamed utilities from v3 to v4
- [Padding](https://tailwindcss.com/docs/padding) (slide 21)
- [Margin](https://tailwindcss.com/docs/margin) (slide 21)
- [Colors](https://tailwindcss.com/docs/colors) (slide 23): 26 colour families in oklch since v4.2
- [Font size](https://tailwindcss.com/docs/font-size) (slide 24)
- [Border radius](https://tailwindcss.com/docs/border-radius) (slide 25)
- [Box shadow](https://tailwindcss.com/docs/box-shadow) (slide 25)
- [Flex](https://tailwindcss.com/docs/flex) (slide 29)
- [Justify content](https://tailwindcss.com/docs/justify-content) (slide 29)
- [Grid template columns](https://tailwindcss.com/docs/grid-template-columns) (slide 30)
- [Max width](https://tailwindcss.com/docs/max-width) (slide 30): The container utility does not center itself
- [Position](https://tailwindcss.com/docs/position) (slide 32)
- [Responsive design](https://tailwindcss.com/docs/responsive-design) (slide 37): Breakpoints and container queries
- [Container queries](https://tailwindcss.com/docs/responsive-design#container-queries) (slide 39)
- [Hover, focus and other states](https://tailwindcss.com/docs/hover-focus-and-other-states) (slide 43)
- [Theme variables](https://tailwindcss.com/docs/theme) (slide 53)
- [Using arbitrary values](https://tailwindcss.com/docs/adding-custom-styles#using-arbitrary-values) (slide 56)
- [Dark mode](https://tailwindcss.com/docs/dark-mode) (slide 57)
- [Managing duplication](https://tailwindcss.com/docs/styling-with-utility-classes#managing-duplication) (slide 61)
- [@apply](https://tailwindcss.com/docs/functions-and-directives#apply-directive) (slide 62)
- [Adding custom utilities](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-utilities) (slide 63)
- [clsx](https://github.com/lukeed/clsx) (slide 64)
- [tailwind-merge](https://github.com/dcastil/tailwind-merge) (slide 64)
- [cn](https://github.com/shadcn-ui/cn) (slide 64): Replaces clsx and tailwind-merge in new shadcn projects
- [Transition property](https://tailwindcss.com/docs/transition-property) (slide 68)
- [Animation](https://tailwindcss.com/docs/animation) (slide 69)
- [WCAG 2.2: contrast (minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) (slide 71)
- [Typography plugin](https://github.com/tailwindlabs/tailwindcss-typography) (slide 74)
- [Forms plugin](https://github.com/tailwindlabs/tailwindcss-forms) (slide 74)
- [shadcn/ui](https://ui.shadcn.com) (slide 75)
- [daisyUI](https://daisyui.com) (slide 75)
- [Headless UI](https://headlessui.com) (slide 75)
- [Dynamic class names](https://tailwindcss.com/docs/detecting-classes-in-source-files#dynamic-class-names) (slide 77)
- [Managing style conflicts](https://tailwindcss.com/docs/styling-with-utility-classes#managing-style-conflicts) (slide 78)

### Sources

- [Tailwind CSS docs](https://tailwindcss.com/docs): Checked against v4.3.3 on 2026-10-14
- [Tailwind CLI](https://tailwindcss.com/docs/installation/tailwind-cli)
- [Preflight](https://tailwindcss.com/docs/preflight)
- [Adding custom styles](https://tailwindcss.com/docs/adding-custom-styles): Arbitrary values, @utility, @layer components
- [Functions and directives](https://tailwindcss.com/docs/functions-and-directives): @apply and @reference
- [Tailwind CSS v4.0](https://tailwindcss.com/blog/tailwindcss-v4)
- [Tailwind CSS v4.3](https://tailwindcss.com/blog/tailwindcss-v4-3): Scrollbar, zoom and tab utilities, with a recap of v4.2
- [Tailwind finds a stable, long-term home with Shopify](https://betakit.com/tailwind-finds-stable-long-term-home-with-shopify-acquisition/): September 2026, the framework stays MIT
