Full-Stack Questions

Tailwind CSS Interview Questions

Tailwind v4, how utilities are generated, dynamic class names, class merging, design tokens and keeping Tailwind code tidy.

Lesson 0227 min read#tailwind#css

On this page

  1. Words used in this lesson
  2. What changed in v4
  3. CSS-first config: @import "tailwindcss" and @theme
  4. How utilities get generated (the engine)
  5. Why dynamic class names break
  6. Arbitrary values, properties and variants
  7. Variants worth knowing
  8. Layers: where custom CSS and @apply fit
  9. cn(), clsx, tailwind-merge and cva
  10. Design tokens, theming and shadcn/ui
  11. Migrating from v3 to v4
  12. Performance
  13. The criticisms of Tailwind, and fair answers
  14. Accessibility with Tailwind
  15. Common mistakes
  16. Interview questions
  17. Key takeaways
  18. What's next

In one line: Tailwind v4 is a build tool that reads your files as plain text, finds class names that look like utilities, and writes only the CSS you used. Everything follows from that: your design tokens live in CSS (@theme), class names must be written out in full (so dynamic names break), and reuse should come from components, not from @apply.

Tailwind questions at 4+ years are rarely about remembering class names. Interviewers want to see that you know how the tool works, so you can explain why a class is missing, why one class beat another, and how to keep a large codebase tidy. They also check that your knowledge is current. Tailwind v4 changed the setup, the config and the names of many classes, and a lot of old interview material is now wrong.

I ran every Tailwind behaviour in this lesson with Tailwind 4.3.3 and compiled the TypeScript with TypeScript 7.0.2. The browser features behind Tailwind (cascade layers, container queries) are explained in HTML & CSS, Lesson 01. The typing side of cva is in TypeScript, Lesson 03.

Words used in this lesson

  • Tailwind CSS is a CSS framework that gives you small single-purpose classes, so you build a design by putting many of them on an element. It is not a set of ready-made components.
  • A utility class is a class that sets one thing, such as p-4 (padding) or text-center. Utility-first means you style by combining utility classes in your markup, instead of writing a new CSS class for each component.
  • A design token is a named design decision, such as "brand colour" or "medium spacing". Everyday comparison: a paint swatch with a label, instead of a colour mixed by eye each time.
  • A CSS variable (also called a custom property) is a name that holds a value in CSS. You write --brand: red; and read it with var(--brand). The browser resolves it while the page runs.
  • A variant is a prefix that makes a utility apply only under a condition, such as hover:bg-blue-600 or md:flex.
  • A cascade layer is a named group of CSS rules, made with @layer. The browser compares layers before it looks at selector strength, and later layers win. Rules that are not in any layer beat all layered rules.
  • A container query is a CSS rule that depends on the width of a parent element, not of the whole window.

What changed in v4

Tailwind v4.0 came out in January 2025, and the current version while I wrote this is 4.3. Many tutorials still describe v3. Remember this table.

TopicOld answer (v3)Correct answer (v4)
Setup@tailwind base; @tailwind components; @tailwind utilities;One line: @import "tailwindcss";
Configtailwind.config.js with content and theme.extendCSS-first: @theme { ... } in your CSS. A JS config is not found automatically. Load it with @config if you must
Finding classesYou list files in contentAutomatic content detection
PostCSStailwindcss is the plugin, plus postcss-import and autoprefixer@tailwindcss/postcss. Imports and prefixes are built in
Custom utilities@layer utilities { .x {} }@utility x { ... }
Dark modedarkMode: 'class' in config@custom-variant dark (...) in CSS
Important!flexflex! (the old form works but is deprecated)
CSS variable in a valuebg-[--brand]bg-(--brand)
BrowsersOld browsers workedSafari 16.4+, Chrome 111+, Firefox 128+

If an interviewer says "put your paths in the content array" or "set darkMode: 'class'", that is v3. Say so politely, then explain the v4 way. It shows you know both.

CSS-first config: @import "tailwindcss" and @theme

Theme variables are special CSS variables, defined inside @theme, that change which utility classes exist. This is the central idea of v4.

CSS
@import "tailwindcss";@theme {  --color-brand-500: oklch(0.62 0.19 259);  --font-display: "Satoshi", sans-serif;  --breakpoint-3xl: 120rem;}

Now bg-brand-500, text-brand-500, font-display and a 3xl: variant exist. The name prefix (the namespace) decides what a variable makes:

Variable starts withMakes
--color-*bg-*, text-*, border-*, fill-* and other colour utilities
--font-*, --text-*Font families, and font sizes
--spacingThe base for p-*, m-*, gap-*, w-*. p-4 is calc(var(--spacing) * 4)
--radius-*, --shadow-*rounded-*, shadow-*
--breakpoint-*, --container-*Variants like md: and @md:
--animate-*animate-* (put the @keyframes inside @theme)

Three things to know:

  • Extend or override. A new name adds a utility. Redefining a default name (--breakpoint-sm: 30rem) changes it. --color-*: initial; removes every default colour. I checked that bg-red-500 then makes nothing.
  • Only used variables are written. I defined --color-unused-500 and used nothing from it. It was missing from the output CSS. @theme static { ... } forces all of them out.
  • @theme inline is for a theme variable that points to another variable, such as --color-primary: var(--primary). Without inline, the utility reads --color-primary, which was resolved where it was defined. A different --primary set on an inner element would not be seen. With inline, the utility gets var(--primary) directly. I compiled it: bg-surface became background-color: var(--surface).

Why @theme and not :root? @theme makes a CSS variable and the classes for it. :root { --x: 1 } makes only a variable. Use :root for values that should not become classes.

How utilities get generated (the engine)

Tailwind v4 has a new engine called Oxide, whose scanner is written in Rust. Tailwind's changelog says full builds are up to 5 times faster and incremental builds (rebuilds after a small change) are over 100 times faster. Those are Tailwind's numbers, and I did not re-measure them.

The key point for interviews is how it finds classes:

  1. It reads each source file as plain text. It does not parse or run your code.
  2. It takes every word that could be a class name.
  3. For each word it asks: "Do I know a utility with this name?" If yes, it writes the CSS. If not, it ignores the word.

That is why Tailwind can make bg-[#316ff6] on demand, and why the output stays small.

Automatic content detection is this scanning with no file list. It skips files in your .gitignore, node_modules, binary files, CSS files and lock files. You can change that:

CSS
@import "tailwindcss";@source "../node_modules/@acme/ui";           /* scan a package that is normally skipped */@source not "../src/legacy";                  /* do not scan this folder */@source inline("{hover:,}bg-red-{100,200}");  /* always write these classes (a "safelist") */

@import "tailwindcss" source(none) turns detection off, and source("../src") sets the folder to scan, which helps in a monorepo (many projects in one repository). I checked that @source inline(...) wrote bg-red-100, bg-red-200 and their hover: versions although no file used them.

Why dynamic class names break

Because the scanner reads text and never runs your code, it can only find class names that are written out in full.

JavaScript
// BROKEN: the scanner sees "bg-", "-600" and a variable. It never sees "bg-red-600".function BadgeBroken({ color }: { color: "red" | "blue" }) {  return <span className={`bg-${color}-600 text-white`}>New</span>;}// WORKS: every full class name appears in the source as text.const colors = {  red: "bg-red-600",  blue: "bg-blue-600",} as const;function Badge({ color }: { color: keyof typeof colors }) {  return <span className={`${colors[color]} text-white`}>New</span>;}

I tested both. The template string made no CSS. The lookup object made both classes. The same is true for text-{{ error ? 'red' : 'green' }}-600 in any template language.

If a value truly arrives at run time, from a database or a user setting, no class can work. The docs suggest an inline style, or a CSS variable that a static class reads:

JavaScript
<button  style={{ "--bg": brandColor } as React.CSSProperties}  className="rounded-md bg-(--bg) px-3 py-1.5 hover:opacity-90"/>

Arbitrary values, properties and variants

An arbitrary value is a one-off value in square brackets, for when the scale lacks what you need: w-[calc(100%-2rem)], bg-[#316ff6], grid-cols-[1fr_500px_2fr].

  • Spaces are written as underscores: grid-cols-[max-content_auto]. (In v3, commas became spaces. In v4 they do not.)
  • CSS variables use round brackets: bg-(--my-var). If the type is unclear, add a hint: text-(length:--my-size) or text-(color:--my-color).
  • Arbitrary properties set any CSS property: [mask-type:luminance].
  • Arbitrary variants write a selector in the class: [&_p]:mt-4 means "every p inside this element gets a top margin" (& is the element, _ is a space).

Arbitrary values are an escape hatch, not a style. If text-[13px] appears ten times, a token is missing. Add --text-caption: 13px to @theme and use text-caption.

Variants worth knowing

A variant is a prefix, and prefixes stack. In v4 they read left to right, like CSS (*:first:pt-0; v3 used the opposite order).

VariantWhat it doesNote
hover:Style on hoverIn v4 it applies only on devices that can hover (@media (hover: hover)). I checked the output
group-hover:Style a child when a parent marked group is hoveredName nested groups: group/card and group-hover/card:
peer-checked:Style an element from an earlier sibling marked peerThe peer must come before the element
has-checked:, has-[>svg]:Style a parent from what it containsUses the CSS :has() selector
not-*:, in-*:"Not" and "inside" conditionsnot-first:mt-2
data-[state=open]:, aria-expanded:Style from attributesFits components that set data-state
@container, @md:, @max-md:Container queriesMark the parent @container. Name it @container/main, then use @lg/main:
motion-reduce:, forced-colors:User preference queriesImportant for accessibility
HTML
<!-- Switches to a row when ITS OWN width is large, not the window's --><div class="@container">  <div class="flex flex-col gap-4 @md:flex-row">    <img class="size-16 rounded-full" src="/a.png" alt="" />    <p class="text-sm">Looks right in a narrow sidebar or a wide column.</p>  </div></div>

Difference in one line: md: asks "how wide is the window?"; @md: asks "how wide is my container?".

For dark mode with a toggle (a .dark class on a parent), override the variant once:

CSS
@import "tailwindcss";@custom-variant dark (&:is(.dark *));

I compiled this, and dark:bg-black became .dark\:bg-black:is(.dark *).

Layers: where custom CSS and @apply fit

Tailwind v4 uses real cascade layers in this order: theme, base, components, utilities. Later layers win, so a utility beats a component rule with no trick.

There is one trap. CSS that is not in any layer beats all layers. I compiled this and read the output:

CSS
/* NOT in a layer: written after the utilities layer, so it beats utilities */.btn { @apply rounded-lg px-4 py-2; }/* In the components layer: utilities can override it */@layer components {  .card { @apply rounded-lg p-4; }}

So <div class="btn rounded-none"> stays round, but <div class="card rounded-none"> is square. Put your own component classes in @layer components.

For a new utility, use @utility. It works with every variant, so md:tab-4 exists automatically:

CSS
@utility tab-4 {  tab-size: 4;}

What does @apply do, and when should you avoid it?

@apply is a directive that copies the CSS of existing utilities into your own CSS rule. It is useful for styling HTML you do not control, such as a third-party widget or Markdown that comes from a database.

Avoid it as your main way to reuse styles:

  • You are back to naming things and jumping between files, which utility-first tried to remove.
  • In v4, a CSS module or a Vue/Svelte <style> block is built alone and cannot see your theme. You must add @reference "../app.css"; at the top.
  • Tailwind's docs rank the options for reuse: render in a loop, edit with multiple cursors, make a component, and only then write custom CSS.

cn(), clsx, tailwind-merge and cva

These three small libraries are in almost every React and Tailwind project. People mix them up.

  • clsx is a function that joins class names and skips false values: clsx("a", isOn && "b"). It knows nothing about Tailwind.
  • tailwind-merge (function twMerge) is a function that removes conflicting Tailwind classes, so the last one wins. It knows that p-4 conflicts with px-2.
  • class-variance-authority (cva) is a function that builds a class string from named variants such as intent="danger", and it makes the prop types.

Most projects copy this helper:

JavaScript
import { clsx, type ClassValue } from "clsx";import { twMerge } from "tailwind-merge";export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Why is it needed? When two classes set the same property, the browser does not use the one written last in the class attribute. It uses the one that comes later in the generated stylesheet. I compiled class="p-4 p-2 grid flex". p-4 won over p-2, and grid won over flex, in any written order. So a component that does "p-4 " + props.className cannot be overridden safely. twMerge fixes this by deleting the loser.

Results from tailwind-merge 3.7:

InputOutputWhy
"px-2 py-1", "p-4"p-4p-4 covers both
"p-4", "px-2"p-4 px-2px-2 is more specific, so both stay
"text-sm", "text-red-500"both keptone is a size, one is a colour
"hover:bg-red-500", "hover:bg-blue-500", "bg-green-500"hover:bg-blue-500 bg-green-500variants are tracked separately

A button with cva, cn and types (this compiled with React 19 types):

JavaScript
import type { ComponentProps } from "react";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "./cn";const buttonVariants = cva(  "inline-flex items-center justify-center rounded-md text-sm font-medium disabled:opacity-50",  {    variants: {      intent: {        primary: "bg-blue-600 text-white hover:bg-blue-700",        danger: "bg-red-600 text-white hover:bg-red-700",      },      size: { sm: "h-8 px-3", md: "h-10 px-4", lg: "h-12 px-6" },    },    compoundVariants: [{ intent: "danger", size: "lg", class: "uppercase" }],    defaultVariants: { intent: "primary", size: "md" },  },);type ButtonProps = ComponentProps<"button"> & VariantProps<typeof buttonVariants>;export function Button({ intent, size, className, ...props }: ButtonProps) {  return <button className={cn(buttonVariants({ intent, size }), className)} {...props} />;}// <Button size="huge" />  -> compile error: "huge" is not a valid size

Note the order: variants first, then the caller's className, all inside cn. That lets className="px-10" replace px-6. If you pass className into buttonVariants, cva only appends it. I tested that: px-6 and px-10 both stayed. Only cn removed the loser.

Version note. The stable package is class-variance-authority (0.7.1). A newer package named cva was still a beta when I checked, so a blog post that shows its API may not match what you install. shadcn/ui's newest templates also re-export cn from a small cn package. The idea is the same: join classes, then merge conflicts.

Limits of tailwind-merge (its own docs list them, so they make good senior answers):

  • It must know your theme. After you add --text-huge, twMerge("text-lg text-huge") keeps both. I ran it. Configure it with extendTailwindMerge({ extend: { theme: { text: ["huge"] } } }), and the result is text-huge.
  • It guesses. An unknown size such as text-2xs is treated as a font size, so twMerge("text-sm text-2xs") drops text-sm, even if your text-2xs does something else.
  • It costs bytes. Its docs say about 5 kB of its roughly 7 kB (minified and gzipped) is the rule table. A call took about a thousandth of a millisecond in my test, so speed is rarely the problem.
  • It gives callers a lot of freedom. For fixed internal classes, twJoin (join without merging) is cheaper and clearer.

Design tokens, theming and shadcn/ui

A good setup has two levels of tokens:

  1. Raw values: --color-blue-600. They say what it is.
  2. Semantic tokens: --primary, --background, --border. They say what it is for. Components use only these.

shadcn/ui is a set of React components that a CLI copies into your project, so you own the code. It uses Radix or similar libraries for behaviour and Tailwind for style. Its CSS shows the two-level idea:

CSS
@import "tailwindcss";@custom-variant dark (&:is(.dark *));:root {  --background: oklch(1 0 0);  --primary: oklch(0.205 0 0);}.dark {  --background: oklch(0.145 0 0);  --primary: oklch(0.985 0 0);}@theme inline {  --color-background: var(--background);  --color-primary: var(--primary);}

Now bg-background and text-primary work, and components need no dark: prefix for those colours. Changing the theme means changing the variables under .dark. @theme inline is needed so the variable is read where it is used, and a .dark or brand block on an inner element is seen. For white-label customers, set the same variables under [data-brand="acme"]. No component changes. Its Button also sets data-slot="button", so parents can target it in CSS, and in React 19 it takes ref as a normal prop (no forwardRef).

Migrating from v3 to v4

My plan:

  1. Check browsers. If you need older ones, stay on v3.4.
  2. Make a branch and run npx @tailwindcss/upgrade (needs Node 20+). It updates dependencies, moves the config to CSS and renames classes in your templates.
  3. Read the diff and look at the pages. Some changes are visual:
v3v4Why it matters
shadow-sm, shadowshadow-xs, shadow-smThe scale moved one step. Same for blur, rounded, drop-shadow
outline-noneoutline-hiddenThe new outline-none really removes the outline
ring (3px, blue)ring-3. Plain ring is 1px, currentColorFocus rings change
border (colour gray-200)colour currentColorBorders with no colour class change colour
bg-gradient-to-r, bg-opacity-50, flex-shrink-0bg-linear-to-r, bg-black/50, shrink-0Renamed or removed
hover:Only on devices with hoverTap-to-open menus may break
  1. Replace postcss-import and autoprefixer with @tailwindcss/postcss (or use @tailwindcss/vite).
  2. Keep the old JS config with @config while you move tokens to @theme. safelist, corePlugins and separator are not supported.
  3. Sass, Less and Stylus are not supported. Tailwind is the preprocessor.

Performance

  • Build time: the new engine is much faster. Do not point @source at huge folders.
  • CSS size: only used utilities are written, so the file stops growing as features grow. Each arbitrary value adds a rule.
  • Runtime: none. It is plain CSS, with no Tailwind JavaScript in the browser.
  • Selectors: space-* and divide-* got a cheaper selector in v4 because the old one was slow on large pages.
  • @apply in many files: Tailwind must process each rule. Using theme variables directly (color: var(--color-red-500)) avoids that work.

The criticisms of Tailwind, and fair answers

CriticismA balanced answer
"The HTML is unreadable."True for big elements. The fix is components, not @apply. A <Button> hides its classes, and Prettier's Tailwind plugin sorts classes
"It is just inline styles."No. You pick from a constrained scale, and you get hover, focus, dark mode and media queries, which inline styles cannot do
"Hard to keep consistent in a team."Put tokens in @theme, limit arbitrary values with lint rules, share a few cva components
"Hard to change later."Often easier. A class edit touches one element, with no hidden global CSS to break

Be honest about the cost: a design change across 100 files is easy only if repeated markup was made a component.

Accessibility with Tailwind

Tailwind does not make a site accessible or inaccessible. Your markup does. But some classes are easy to misuse:

  • Never remove focus without a replacement. v4 outline-none sets outline-style: none, and keyboard users lose the focus mark. Use focus-visible:outline-2 focus-visible:outline-offset-2. outline-hidden hides the outline in normal view but keeps it in forced-colours (high-contrast) mode.
  • sr-only hides text visually but keeps it for screen readers. Use it for icon-only buttons: <span class="sr-only">Close</span>.
  • motion-reduce: turns off animation for people who asked for less motion.
  • Do not use colour alone, and check contrast for each colour pair (4.5 to 1 for body text). text-gray-400 on white fails.
  • Style from state attributes (aria-expanded:, aria-invalid:), so the look follows the real state that assistive tools also read.
  • hover: is an extra. Never put important information only in hover.

Common mistakes

  1. Building class names from pieces (bg-${color}-500).
  2. Writing custom CSS outside @layer, then wondering why utilities cannot override it.
  3. Using @apply for everything, then needing @reference in every module.
  4. Not using cn(), so a className prop cannot override defaults. Or passing className into cva() and expecting a merge.
  5. Forgetting to configure tailwind-merge after adding custom tokens.
  6. Copying v3 advice (content arrays, @tailwind base, outline-none as a reset).
  7. Raw colours everywhere instead of semantic tokens.
  8. Relying on class order in the class attribute. The stylesheet decides.

Interview questions

What is utility-first CSS, and why would you choose it? (must know)

It means I style by combining small classes that each do one job, such as flex, p-4 and text-sm, instead of writing a new CSS class per component. I choose it because I stop spending time on names, a change touches only the element I edit, the CSS file stops growing, and values come from a fixed scale, so the UI stays consistent. The cost is long class lists, which I handle with components. It does not replace knowing CSS: I still need the cascade, layout and the box model.

What changed in Tailwind v4, and how do you configure it now? (must know)

Setup is @import "tailwindcss"; in one CSS file. Config is CSS-first: tokens go in @theme, custom utilities in @utility, custom variants in @custom-variant. Content detection is automatic, so there is no content array. The engine is new, with a Rust scanner. The PostCSS plugin moved to @tailwindcss/postcss. It targets modern browsers (Safari 16.4+, Chrome 111+, Firefox 128+). A JavaScript config still works if I load it with @config, but it is not found automatically.

A class works locally but is missing in production. What do you check? (must know)

First, does the class name appear in full in a source file? The scanner only reads text, so `bg-${color}-600` is never found. Second, is the file scanned? Files in .gitignore, node_modules and CSS files are skipped, so a class that appears only in a package needs @source. Third, does the class still exist in v4? Many v3 names were renamed or removed. For classes that come from a CMS or database, I use @source inline(...), or a CSS variable with an inline style.

How does Tailwind generate its CSS, and why do dynamic class names break? (must know)

It scans my files as plain text and checks every class-like token against its known utilities and variants, including arbitrary values like w-[37px]. Matches become rules, the rest are ignored, and the result is one stylesheet in the right layer order. It never runs my code, so `text-${tone}-600` contains no complete class. I map values to full class names in an object, use cva for variants, and for true run-time values I set a CSS variable in an inline style and read it with bg-(--bg).

What is the difference between @theme and :root? And what does @theme inline do? (senior signal)

@theme defines a CSS variable and tells Tailwind to make utilities or variants for it. --color-brand-500 in @theme gives me bg-brand-500. A variable in :root is only a variable. Theme variables must be top level. With @theme inline, a utility uses the variable's value instead of a reference: bg-primary becomes var(--primary), not var(--color-primary). I need it when one variable points to another, as in shadcn/ui theming, because the lookup must happen where the class is used and not where the theme variable was defined.

When are arbitrary values good, and when are they a smell? (must know)

They are good for a real one-off: a calc(), a special grid template, or a CSS variable (bg-(--bg)). They are a smell when a value repeats or replaces the scale. text-[13px] in twelve places should be a token in @theme. Each one also adds a CSS rule. For a very complex value, an inline style or a small CSS rule is easier to read.

Explain group, peer and has-. (must know)

group marks a parent, and group-hover: styles a child when that parent is hovered. peer marks an element, and peer-checked: styles a later sibling, for example a label after a checkbox. has-* goes the other way: it styles an element from what it contains, using :has(). has-checked:bg-blue-50 on a label styles the label when the input inside is checked. I use has-* when the state is in a child and the style belongs to the parent, so I do not need to reorder the HTML for peer. I name groups (group/card) when they are nested.

How do you build a component that responds to its own width? (senior signal)

With container queries. The parent gets @container, and children use @md:, @lg: and @max-md:. They respond to the parent width, not the window, so the same card works in a narrow sidebar and a wide column. For nested containers I name them: @container/main with @lg/main:flex. Sizes come from --container-* theme variables. I use media queries (md:) for page-level layout, and container queries for reusable components.

How do you do dark mode, including a manual toggle? (must know)

By default dark: follows the system setting (prefers-color-scheme). For a toggle I override the variant: @custom-variant dark (&:is(.dark *)); and put class="dark" on <html>. In a bigger app I prefer semantic tokens: --background in :root and .dark, exposed through @theme inline, so components use bg-background and need almost no dark: classes. I set the class before the first paint (a tiny inline script) to avoid a flash of the wrong theme.

When is @apply right, and where do your own CSS classes go? (trap)

@apply is right for markup I do not control: Markdown from a CMS, or a third-party widget. It is the wrong tool for reuse, where I use a loop and then a component. In v4, @apply in a CSS module or a Vue/Svelte style block also needs @reference. The trap is where the CSS goes. Rules outside any @layer beat all layers, so .btn { ... } would beat my utilities, and class="btn rounded-none" would stay round. I put component classes in @layer components and new utilities in @utility.

Trap: class="p-4 p-2". Which padding wins? And flex grid? (trap)

The order in the attribute does not matter. The winner is the rule that comes later in the generated stylesheet. I compiled it: p-4 comes after p-2, so p-4 wins, and grid comes after flex, so grid wins. A more specific property such as px-8 is also placed after p-*. This is why I never put two conflicting utilities on one element on purpose, and why components use cn() for overrides.

Why do you need both clsx and tailwind-merge in cn()? (must know)

They do different jobs. clsx joins class names and drops false values, so I can write conditions, but it does not know which Tailwind classes conflict. tailwind-merge removes conflicting Tailwind classes so the last one wins. Because the winner is decided by the stylesheet and not by the attribute, a className="p-2" passed into a component with p-4 may lose. So cn = (...x) => twMerge(clsx(x)).

What are the weak points of tailwind-merge? (senior signal)

It only knows the default theme, so with a custom --text-huge, text-lg text-huge keeps both until I configure extendTailwindMerge with theme: { text: ['huge'] }. It guesses for names that look like Tailwind, so a custom text-2xs can delete text-sm. It adds bytes (about 5 kB of its 7 kB is the rule table). And it lets callers override anything, which can make a design system hard to change. For fixed internal classes I use twJoin, or I design variants so merging is not needed.

Explain cva. Why not template strings? (must know)

cva takes a base class string and an object of variants and returns a function: button({ intent: "danger", size: "lg" }). I get defaults, compound variants (rules for combinations, like danger plus large) and VariantProps for the prop types, so size="huge" is a compile error. Template strings would work, but every component would invent its own logic with no typed API. I pass the result through cn() with the caller's className so callers can still override. And I use the stable class-variance-authority package.

Design tokens and theming for a light theme, a dark theme and white-label customers. (senior signal)

Two levels. A raw palette in @theme, and semantic tokens (--background, --primary, --border) as plain CSS variables. Components use only semantic classes like bg-background, mapped in @theme inline. Light and dark are two sets of values (:root and .dark), and each customer is a set under [data-brand="acme"] on <html>. No component code changes. I write colours in OKLCH, a colour format whose lightness number matches how bright the colour looks, so scales are even. I add a contrast check for each theme.

How would you migrate a large app from v3 to v4? (senior signal)

I check browser support first. On a branch I run npx @tailwindcss/upgrade and read the whole diff. I compare pages visually, because some changes are silent: the shadow, radius and blur scale shifts, ring width, default border colour, outline-none, hover on touch devices and space-* spacing. I replace postcss-import and autoprefixer with @tailwindcss/postcss or the Vite plugin. I keep the old config with @config and move tokens to @theme slowly. Plugins that need safelist or corePlugins must change. I ship it as its own release with screenshots.

How do you keep Tailwind code accessible? (must know)

I start with the right HTML element. Then I use classes carefully. I keep a visible focus style with focus-visible: and never use outline-none alone. I use sr-only for hidden labels, motion-reduce: for animation and forced-colors: where needed. I check contrast for each colour pair. I tie styles to real state with aria-* and data-* variants. And I never put information only in hover or only in colour.

Key takeaways

  • Tailwind v4 is CSS-first: @import "tailwindcss", @theme, @utility, @custom-variant. A JS config is not found automatically.
  • It scans files as text, so class names must be written in full. Dynamic names break. Use lookup objects, cva, @source inline(...) or CSS variables.
  • @theme makes variables and utilities. @theme inline is for variables that point to other variables, which is how theming works.
  • Between two conflicting utilities, the one later in the stylesheet wins. Use cn() (clsx + tailwind-merge) in components, and configure tailwind-merge when you add custom tokens.
  • Put your own component CSS in @layer components, because unlayered CSS beats utilities. Prefer components over @apply.
  • Know the v3 to v4 changes: renames, new defaults, hover only on hover devices, browser baseline.
  • Keep accessibility visible: focus styles, sr-only, motion-reduce, contrast and state attributes.

What's next

Lesson 03: TypeScript Interview Questions. You just typed a cva button. Next: what changed in TypeScript 6 and 7, the tsconfig flags that matter, module settings, type-safe API layers, and the errors that confuse people.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Related series