Tailwind with Angular: ng add tailwindcss, @reference in Component Styles, Dynamic Classes and Headless Components
In Angular, Tailwind runs on the global styles.css; component CSS is processed separately, so @apply needs @reference there and carries fallback values and @property blocks into every component. In bookstore-web we show what ng new --style tailwind and ng add do, the bundle and build cost of @reference across 40 components, why Tailwind syntax breaks in SCSS, dynamic classes with full names and signals, and styling an Angular Aria Listbox with aria-selected.
Version note: The examples in this post were run with Angular 22.2, TypeScript 6.0, Tailwind CSS 4.3.5 (@tailwindcss/postcss), Angular Aria 22.2, Node.js 24 and Playwright 1.65 (Chromium 158). Angular 22.2 requires TypeScript 6.0, so the project uses 6.0 rather than TypeScript 7.
Guess First
We set up bookstore-web, the Angular client of Bookstore, the online bookstore used as the shared example across this site's series, with Tailwind. The global styles.css file holds Tailwind and Bookstore's tokens:
@import 'tailwindcss';
@theme {
--color-ink-900: oklch(0.21 0.03 264);
/* ... brand colors, radius and font */
}
In the book card component's own CSS file we wrote a single rule for the price:
.price {
@apply text-lg font-bold text-ink-900;
}
What happens when we run ng build? Does the price come out 18px and bold, does the rule silently stay empty, or does the build break?
In this post we connect Tailwind to Angular: what ng new --style tailwind and ng add tailwindcss do exactly, @reference in component styles and its cost, why the Angular guide's SCSS advice contradicts the Tailwind docs, dynamic class lists with signals, and a headless filter built with Angular Aria. We covered Angular itself in the Angular series and component boundaries in the UI Architecture Patterns series; here we only look at the Tailwind side.
ng new --style tailwind and ng add tailwindcss
In Angular CLI 22.2.7 the --style options of ng new are css, less, sass, scss and tailwind. We created bookstore-web with Tailwind directly:
npx @angular/cli@22.2.7 new bookstore-web --directory web --style tailwind --ssr false --zoneless --ai-config none --skip-git
CREATE web/.postcssrc.json (53 bytes)
CREATE web/src/styles.css (104 bytes)
CREATE web/src/app/app.css (0 bytes)
...
✔ Packages installed successfully.
In an existing project you get the same result with ng add tailwindcss. We ran it in an empty project created with plain CSS:
npx ng add tailwindcss --skip-confirmation
› Found compatible package version: 4.3.5.
Cleaning up temporary dependency 'tailwindcss'...
The tailwindcss package does not provide `ng add` actions.
The Angular CLI will use built-in actions to add it to your project.
CREATE .postcssrc.json (53 bytes)
UPDATE src/styles.css (104 bytes)
UPDATE package.json (875 bytes)
Two lines in the log are interesting: the tailwindcss package has no ng add schematic of its own; a schematic built into the Angular CLI does the work. And it makes three changes: it adds tailwindcss, @tailwindcss/postcss and postcss to package.json, creates a .postcssrc.json, and adds a single line to styles.css:
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
/* You can add global styles to this file, and also import other style files */
@import 'tailwindcss';
The manual setup is exactly that, and the steps in the Angular guide are these same three. One detail: the schematic writes "tailwindcss": "^4.1.12" into package.json; today npm installed 4.3.5 from that range. Don't forget to commit package-lock.json so that everyone on the team uses the same version.
How the Angular Build Runs Tailwind
@angular/build processes style files in its own pipeline, and when it finds a .postcssrc.json (or postcss.config.json) in the project root it applies the PostCSS plugins in it to every style file. The key word is "every": the global styles.css is processed once, and the CSS each component links with styleUrl is processed separately. Since Tailwind sees @import 'tailwindcss' and @theme in the global file, it builds the whole system there; it scans the templates and writes the utilities into the styles.css output. The component CSS is a different file; while processing it, Tailwind sees neither @import 'tailwindcss' nor @theme.
npx ng build
Initial chunk files | Names | Raw size | Estimated transfer size
main-53KIRNCR.js | main | 250.93 kB | 69.12 kB
styles-CR5T2JGC.css | styles | 9.73 kB | 2.44 kB
| Initial total | 260.66 kB | 71.56 kB
All the utilities are in the styles file; the CSS for every class you write in component templates comes from there. Component CSS, on the other hand, is compiled into the JavaScript bundle as a string and scoped to that component by Angular's view encapsulation.
@apply and @reference in Component Styles
The answer to the guess: the build breaks.
✘ [ERROR] Cannot apply unknown utility class `text-lg`. Are you using CSS modules or similar and missing `@reference`? https://tailwindcss.com/docs/functions-and-directives#reference-directive [plugin angular-css]
src/app/catalog/book-card.css:1:0:
1 │ .price {
╵ ^
While processing book-card.css, Tailwind has no theme it knows of; text-lg is an unknown class to it. The error message also gives the fix: link the global stylesheet into the component CSS as a reference.
@reference "../../styles.css";
.price {
@apply text-lg font-bold text-ink-900;
}
Unlike @import, @reference doesn't copy the file's contents into the output; it only loads the theme and utility definitions so that @apply works. The build passed, and the price showed up at 18px, bold, in the ink-900 color. But the component CSS that reaches the browser shows the cost:
@layer properties;.price[_ngcontent-ng-c1490267311]{font-size:var(--text-lg, 1.125rem);line-height:var(--tw-leading, var(--text-lg--line-height, calc(1.75 / 1.125)));--tw-font-weight: var(--font-weight-bold, 700);font-weight:var(--font-weight-bold, 700);color:var(--color-ink-900, oklch(.21 .03 264))}@property --tw-font-weight{syntax: "*"; inherits: false;}@layer properties{@supports ((-webkit-hyphens: none) and (not (margin-trim: block))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))){*[_ngcontent-ng-c1490267311], [_ngcontent-ng-c1490267311]:before, [_ngcontent-ng-c1490267311]:after, [_ngcontent-ng-c1490267311]::backdrop{--tw-font-weight: initial}}}
Three utilities came out as 676 characters. In reference mode Tailwind adds a fallback value to every var() (var(--text-lg, 1.125rem)), because the component CSS doesn't know whether the theme variables exist on :root. Utilities that work through @property, such as font-bold, also carry the @property definition and a @layer properties fallback for older browsers into every component separately; Angular's encapsulation even scoped the * selector in that fallback to the component.
The Cost of @reference: Measured with 40 Components
How does this cost grow with the number of components? We added 40 small components to a copy of bookstore-web and gave each one the same box style in two ways. The first is plain CSS with theme variables:
.box { border-radius: var(--radius-card); background: var(--color-brand-50); padding: 1rem; color: var(--color-ink-900); }
The second is @reference and @apply:
@reference "../../styles.css";
.box { @apply rounded-card bg-brand-50 p-4 text-ink-900 shadow-sm; }
We deleted the .angular cache each time and ran ng build three times:
| Approach | main bundle (raw) | main bundle (transfer) | Build time (3 runs) |
|---|---|---|---|
| --- | --- | --- | --- |
var(--…), not processed by Tailwind | 266.37 kB | 69.69 kB | 1.013 s, 0.983 s, 0.955 s |
@reference + @apply | 356.05 kB | 70.01 kB | 1.394 s, 1.247 s, 1.055 s |
The raw bundle grew by 90 kB: every component carries its own @property definitions and fallbacks. Compression catches the repeated text well, so the difference in transfer size is small, but the browser still parses the extra 90 kB of strings and adds a separate <style> for each component. The build was also slower in every run, because Tailwind loads styles.css again and builds the design system for each of the 40 files. Tailwind's compatibility page gives the same warning for CSS modules and Vue/Svelte style blocks: Tailwind runs separately for every file.
The result is an order of preference. First, use utilities in the template. If component CSS is really needed (third-party markup that can't be reached without ::ng-deep, a complex animation), use the theme variables directly as var(--color-brand-50); even though Tailwind never touches that file, the variables work because they are on :root. @reference and @apply come last.
SCSS: The Angular Guide and the Tailwind Docs
Angular's Tailwind guide shows @use 'tailwindcss'; in styles.scss for projects that use SCSS. Tailwind's compatibility page, on the other hand, says v4 was not designed to be used together with preprocessors such as Sass, Less or Stylus, and tells you to think of Tailwind itself as your preprocessor. The two sources disagree; we tried out what that means in practice.
The first interesting finding came from the CLI itself. In a project created with SCSS, ng add tailwindcss didn't touch styles.scss:
CREATE src/tailwind.css (23 bytes)
CREATE .postcssrc.json (53 bytes)
UPDATE angular.json (2067 bytes)
UPDATE package.json (876 bytes)
It created a separate src/tailwind.css (containing only @import 'tailwindcss';) and set the styles array in angular.json to ["src/tailwind.css", "src/styles.scss"]. In other words, the CLI keeps Tailwind in a CSS file.
Then, as in the guide, we removed tailwind.css and wrote @use 'tailwindcss'; into styles.scss. In simple use the build passed; text-3xl and a .price rule written with @apply were in the output. The trouble started when we wrote Tailwind v4's own syntax into the SCSS file. @custom-variant and @source not passed, but a namespace reset and a functional utility broke in the Sass compiler:
✘ [ERROR] Error: expected "{".
╷
3 │ --color-*: initial;
│ ^
╵
../../../../src/styles.scss 3:21 root stylesheet [plugin angular-sass]
✘ [ERROR] Error: Expected expression.
╷
3 │ background-image: repeating-linear-gradient(45deg, --value(--color-*, [color]) 0 6px, transparent 6px 12px);
│ ^
╵
../../../../src/styles.scss 3:71 root stylesheet [plugin angular-sass]
The reason is the order: Angular runs Sass first, PostCSS and Tailwind come after. If Tailwind's syntax isn't valid for Sass, Tailwind never gets its turn. Our recommendation is the same as what the CLI does: keep Tailwind's import, @theme and @utility definitions in a .css file. You can keep using SCSS in components, as long as those files contain no Tailwind directives. We covered which Sass jobs native CSS now takes over in the first post of the Sass/SCSS series, and how Sass is wired into the Angular build in its last post.
Dynamic Classes: [class.x], [class] and Signals
The full name rule applies in Angular too: Tailwind scans the TypeScript file and the template as plain text. In the book card, stock status is a set of three values; we keep the classes in a map with their full names and pick them with signals:
import { Component, computed, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Book } from './book';
type StockState = 'in-stock' | 'low' | 'sold-out';
// full class names per state: Tailwind only generates what it finds in the source
const STOCK_CLASSES: Record<StockState, string> = {
'in-stock': 'bg-emerald-50 text-emerald-700',
low: 'bg-amber-50 text-amber-800',
'sold-out': 'bg-slate-100 text-slate-500',
};
@Component({
selector: 'app-book-card',
imports: [CurrencyPipe],
templateUrl: './book-card.html',
styleUrl: './book-card.css',
})
export class BookCard {
readonly book = input.required<Book>();
protected readonly stock = computed<StockState>(() => {
const count = this.book().stock;
return count === 0 ? 'sold-out' : count < 5 ? 'low' : 'in-stock';
});
protected readonly stockClasses = computed(() => STOCK_CLASSES[this.stock()]);
protected readonly soldOut = computed(() => this.stock() === 'sold-out');
}
<article class="flex h-full flex-col gap-2 rounded-card border border-slate-200 bg-white p-4 shadow-sm" [class.opacity-60]="soldOut()">
<span class="stock-badge w-fit rounded-full px-2 py-0.5 text-xs font-medium" [class]="stockClasses()">
{{ stock() === 'sold-out' ? 'Sold out' : stock() === 'low' ? 'Only ' + book().stock + ' left' : 'In stock' }}
</span>
<h3 class="font-display text-lg font-semibold text-ink-900">{{ book().title }}</h3>
<p class="text-sm text-slate-600">{{ book().author }} · {{ book().format }}</p>
<p class="price mt-auto">{{ book().price | currency: 'TRY' : 'symbol-narrow' : '1.2-2' : 'en' }}</p>
</article>
The difference between the two bindings: [class.opacity-60] ties a single class to a boolean; [class] adds the classes in a string and removes the old ones when the value changes. Angular merges the [class] binding with the static class attribute, so the badge keeps its fixed classes such as rounded-full px-2. Keeping full names in a map instead of writing 'bg-' + color + '-50' lets the scanner see the classes, and lets TypeScript catch a missing state (Record<StockState, string>) at compile time.
A Headless Filter: Angular Aria Listbox
Bookstore's format filter is a multi-select listbox. Angular Aria's Listbox and Option directives handle keyboard navigation, aria-selected, role and tabindex; the look lives entirely in Tailwind variants:
import { Component, model } from '@angular/core';
import { Listbox, Option } from '@angular/aria/listbox';
import { BookFormat } from './book';
@Component({
selector: 'app-format-filter',
imports: [Listbox, Option],
template: `
<div ngListbox multi selectionMode="explicit" orientation="horizontal" [(value)]="selected" aria-label="Format" class="flex gap-2">
@for (format of formats; track format) {
<div ngOption [value]="format" [label]="format"
class="cursor-pointer rounded-full border border-slate-300 px-3 py-1 text-sm
aria-selected:border-brand-600 aria-selected:bg-brand-600 aria-selected:text-white
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500">
{{ format }}
</div>
}
</div>
`,
})
export class FormatFilter {
protected readonly formats: readonly BookFormat[] = ['Hardcover', 'Paperback', 'E-book'];
readonly selected = model<BookFormat[]>([]);
}
The catalog page binds it to a signal with <app-format-filter [(selected)]="formats" /> and filters the visible books with computed. We built the app, served it on 127.0.0.1:52862 and measured with Playwright:
node scripts/05-catalog.mjs
Dune In stock bg oklch(0.979 0.021 166.113) card opacity 1
The Hobbit Only 3 left bg oklch(0.987 0.022 95.277) card opacity 1
Emma Sold out bg oklch(0.968 0.007 247.896) card opacity 0.6
Middlemarch In stock bg oklch(0.979 0.021 166.113) card opacity 1
load Hardcover[selected=false active=true bg rgba(0, 0, 0, 0)] Paperback[selected=false active=false bg rgba(0, 0, 0, 0)] E-book[selected=false active=false bg rgba(0, 0, 0, 0)] -> 4 of 4 books
click Paperback Hardcover[selected=false active=false bg rgba(0, 0, 0, 0)] Paperback[selected=true active=true bg oklch(0.54 0.178 264)] E-book[selected=false active=false bg rgba(0, 0, 0, 0)] -> 2 of 4 books
ArrowRight Space Hardcover[selected=false active=false bg rgba(0, 0, 0, 0)] Paperback[selected=true active=false bg oklch(0.54 0.178 264)] E-book[selected=true active=true bg oklch(0.54 0.178 264)] -> 3 of 4 books
focused: E-book, outline solid 2px
.price: 18px 700 oklch(0.21 0.03 264)
The badges took three colors depending on stock, and the sold-out Emma card is at 60 percent opacity. In the filter, clicks and the keyboard (right arrow, Space) change the same state, and the color comes straight from aria-selected.
This measurement also settled two settings. In the first attempt we had not set selectionMode; in the default follow mode the focused option is selected automatically, and the page opened with "Hardcover" already selected and the list at "1 of 4 books". For a multi-select filter, explicit mode is right: the selection changes only on click, Space or Enter. Second, the active option carries data-active="true", but as the first line of the measurement shows, the attribute sits on the first option even when the listbox has never been focused. Had we drawn the focus ring with data-[active=true]:, it would have shown on page load too; since roving tabindex moves the real DOM focus, focus-visible: is the right variant.
In Production
The anyComponentStyle budget in angular.json warns at 4 kB and fails at 8 kB by default. Component CSS written with @reference grows faster than you expect because of fallback values and @property blocks; when you see a budget warning, the @apply lines are the first place to look.
The global styles.css carries the utilities of the whole application, and the templates of lazy-loaded routes are scanned too; so the classes in lazy routes are in the CSS loaded first as well. In bookstore-web that is 9.73 kB (2.44 kB transferred). In a very large application, tracking the size of this file in every release is the same check we recommended for Bookstore.Web.
package-lock.json pins the Tailwind version, and the Angular version too. Don't upgrade both in the same PR; a Tailwind minor release (like the logical utilities in 4.2) brings new class names, and it gets harder to tell what changed in the diff.
Common Pitfalls
The first is the pitfall from the guess: @apply in component CSS without @reference. The error message is clear, but adding @reference to every component brings the cost we measured; consider moving the styles into the template first.
The second is giving the wrong @reference path. The path is relative to the component CSS file: ../../styles.css for src/app/catalog/book-card.css. Move the component to another folder and the path changes, and the build breaks with the same error; one more reason to use @reference sparingly.
The third is writing Tailwind directives into a .scss file. Because a simple @use works, the day you add a @theme reset or a @utility there, the build breaks.
The fourth is concatenating class names in the template: [class]="'bg-' + color() + '-50'". As in the server-side Razor example, the HTML looks right but there is no rule in the CSS.
A Small Experiment
★☆☆ Remove selectionMode="explicit" in format-filter.ts, then run ng build and 05-catalog.mjs. Note the selected option and the book count in the first line.
★★☆ Write the .price rule in book-card.css without @reference, using only theme variables (such as font-size: var(--text-lg)). Compare the main bundle size in the build and the length of the component <style> in the browser with the previous version.
★★★ Next to the format filter, add a "Sort by" menu with Angular Aria's Menu directive. Style the menu's open state with aria-expanded: and the selected sort order with aria-checked:, and verify with Playwright that it works with the keyboard (Enter, arrow keys, Escape).
In the next post we look at a growing project: a small class map approach for component APIs with variants, tailwind-merge for merging conflicting classes, class sorting with Prettier, and the migration from v3 to v4 with npx @tailwindcss/upgrade.