Tailwind CSS with Vue
Style with utility classes, keep tokens in @theme, use dark, breakpoint and container variants, and avoid the two Tailwind bugs Vue makes easy.
What you'll learn
When you finish, you can do these.- 1Add Tailwind to Vite and write class names it can find
- 2Turn design tokens into Tailwind classes with @theme
- 3Change styles on hover, in dark mode and by width
- 4Keep repeated classes in a component with props
Picture a small online shop that sells mugs and teapots. Its page has a card for each product: a name, a price and an Add button. In plain CSS, you invent a class name for the cards and write a rule for it in a <style> block. Tailwind CSS takes another route: small single-purpose classes called utilities, written in the template. rounded-card bg-brand-soft p-4 gives a card rounded corners, a pale blue background and padding, and the CSS your visitors download holds only the utilities you used.
The Code tab holds that shop page, styled with utilities only: four product cards and a Dark button that turns the page dark. By the end you can add a red Remove button and set the cards in two columns. You need one idea from Dark mode and theming: a design token, a named value the whole app shares, written as a CSS variable such as --accent.
How Tailwind finds your classes
In a real project, Tailwind finds your classes through a plugin: an add-on to Vite, the build tool that prepares your files for the browser. The plugin reads your source files as plain text, collects every word that could be a class name, and writes CSS for the ones that match a utility. A class that never appears in your files as a complete word gets no CSS.
- Your .vue files
- Scan for class namesreads files as plain text
- Generate only those
- CSS bundle
Setup takes two steps. First, add the plugin from the @tailwindcss/vite package to vite.config.ts, Vite's settings file (the Code tab has the whole file):
plugins: [vue(), tailwindcss()],
Then import Tailwind at the top of main.css, the stylesheet the whole app loads:
@import "tailwindcss";
Every utility in your .vue files now gets its CSS when Vite builds the app.
The Code tab works differently: its preview loads Tailwind's browser build, which styles every class it sees on the live page. It is too slow for a real site, and it hides one bug, as "What goes wrong" explains.
Tokens and variants
Tokens turn your own colours and sizes into utilities, and variants decide when a utility applies. @theme is a block in main.css that holds your design tokens. Each token whose name starts with a prefix Tailwind knows, such as --color- or --radius-, creates utilities. The Code tab's main.css declares three, with the utilities each one gives in a comment:
@theme {
--color-brand: #1566c5; /* bg-brand, text-brand, border-brand, outline-brand */
--color-brand-soft: #e3effc; /* bg-brand-soft */
--radius-card: 1.25rem; /* rounded-card */
}
The heading "Shop" is text-brand, and each card is rounded-card bg-brand-soft. Built-in colours such as slate-800 are Tailwind's own tokens.
A variant is a prefix that applies a utility only under a condition: hover:bg-brand/85 under the pointer (the /85 means 85 % opacity), focus-visible:outline-2 on keyboard focus, dark:bg-slate-800 in dark mode.
By default dark: follows the device setting through prefers-color-scheme, and a theme switch on the page has no effect on it. To make it follow a .dark class on <html>, which is what a switch sets, redefine the variant in main.css:
@custom-variant dark (&:where(.dark, .dark *));
In the Code tab, the Dark button puts that class on <html>: every dark: utility applies, the page turns dark, and the button reads Light.
Breakpoints, the window widths where a layout changes, are variants too. They are mobile-first: a class with no prefix applies at every width, and sm:grid-cols-2 gives two columns once the window is 40rem (640 px) wide.
Container queries ask about an element instead of the window. Put @container on a parent and @3xs:grid-cols-2 on a child, and the child switches to two columns when the parent is at least 16rem (256 px) wide. A card that appears in a wide page and in a narrow sidebar needs the container version.
Utilities in components
When the same class list repeats, make a component rather than an @apply rule, a CSS class built from utilities. In the Code tab, the Dark button and the Add button on each card come from one component, Button.vue. Its template holds the classes every button shares and binds one more set with :class:
<button
type="button"
class="inline-flex min-h-11 items-center rounded-lg px-4 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
:class="variants[variant]"
>
<slot />
</button>
That set comes from variants, an object in Button's <script setup> that maps each of its two looks, primary and secondary, to a string of full class names. Here a variant is a look of Button, not a Tailwind prefix:
const variants = {
primary: 'bg-brand text-white hover:bg-brand/85',
secondary: 'bg-slate-200 text-slate-900 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-100',
}
Vue merges the plain class with the bound :class, so each button gets the shared classes plus the string for its look, picked by its variant prop. The Add buttons use the default, primary, and the Dark button asks for secondary. Every name appears whole in the file, so the scanner finds all of them.
What goes wrong
The first mistake is building a class name from pieces. Say Button had a color prop, red or green, and built its background class with a template string, a string in backticks where ${color} becomes the prop's value:
const colorClass = `bg-${color}-600`
The scanner reads that line as text and finds no class in it. With the Vite plugin, bg-red-600 gets no CSS unless the full name appears somewhere else in your source, so the button has no background and nothing warns you. In the Code tab it happens to work, because the browser build reads the finished class from the page. Write every possible name in full and pick one:
const colorClass = { red: 'bg-red-600', green: 'bg-green-600' }[color]
Both names now appear whole in the file, so the scanner finds them.
The second mistake is a class from outside that fights one inside. In a compact list, the Add buttons should be narrower, with px-2, less padding at the sides, instead of Button's own px-4:
<Button class="px-2">Add</Button>
The button keeps its width. Vue merged the classes correctly into px-4 px-2, but the order inside the attribute decides nothing. Two utilities that set the same property compete like any CSS rules: the one written later in the stylesheet wins, and Tailwind writes px-2 before px-4.
class="px-8" does widen the button, because Tailwind writes px-8 after px-4: that is why this bug hides so well. Let the component own the choice with a prop:
<Button compact>Add</Button>
Button gets a compact prop, true or false, and binds compact ? 'px-2' : 'px-4' in place of its fixed px-4 (Try it 3). A component that must accept any class from outside can use the tailwind-merge package, which drops the losing one.
What to remember
- With the Vite plugin, Tailwind writes CSS only for class names it finds as complete words in your source files.
- Tokens in
@themecreate utilities:--color-brandgivesbg-brand,text-brandand the rest. dark:follows the device setting until@custom-variant darkpoints it at a class.sm:and the other breakpoints read the window;@containerwith@3xs:and friends reads a parent element.- Repeated class lists become a component with a map of full class names. A conflicting class from outside needs a prop or
tailwind-merge.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- In Button.vue, add
danger: 'bg-red-600 text-white hover:bg-red-700',to thevariantsmap and'danger'to the prop type. In App.vue, add<Button variant="danger">Remove</Button>after Add. A red Remove button appears in every card. - Add
sm:grid-cols-2to the<ul>. Nothing changes:sm:needs a window 640 px wide, and the preview is narrower. Now add@containerto the<main>and replacesm:grid-cols-2with@3xs:grid-cols-2. The cards sit in two columns, because<main>is wider than 256 px. - Give the Add button
class="px-8": it widens. Change it toclass="px-2": nothing happens. Fix it with a prop: addcompact?: booleanto Button's props, removepx-4from its class list, bind:class="[variants[variant], compact ? 'px-2' : 'px-4']", and write<Button compact>Add</Button>. The Add buttons turn narrow.