tailwindlabs/tailwindcss
github.com/tailwindlabs/tailwindcss ↗ · 547 files · 5.3 MB
A utility-first CSS framework for rapidly building custom user interfaces.
Overview
This is the Tailwind CSS v4 monorepo: a CSS-first utility framework whose compiler is a TypeScript AST pipeline (`packages/tailwindcss`) paired with a Rust candidate scanner compiled to a NAPI/WASM native addon (`crates/oxide` → `@tailwindcss/oxide`). Configuration moved out of `tailwind.config.js` and into CSS at-rules — `@theme`, `@utility`, `@custom-variant`, `@source`, `@plugin`, `@config`, `@apply` — with a dedicated `compat/` layer that replays legacy v3 JS configs and plugins onto the new design system. It ships as a fan of thin integration packages (CLI, PostCSS, Vite, webpack, Turbopack, browser CDN build, Bun-compiled standalone binary) that all wrap the same `compile()` + `Scanner` pair, plus an AST-driven `@tailwindcss/upgrade` codemod tool for v3→v4 migration. It targets web developers and build-tool authors; what's distinctive is the split-language architecture and the aggressive incremental-rebuild caching that appears in every integration.
System map
The repo is a pnpm workspace (`pnpm-workspace.yaml`) orchestrated by Turborepo (`turbo.json`) layered over a Cargo workspace (`Cargo.toml`, `members = ["crates/*"]`). The compiler core lives in `packages/tailwindcss` and is pure TypeScript with no filesystem access — all IO is injected via `loadModule`/`loadStylesheet` callbacks. `@tailwindcss/node` is the Node IO adapter (enhanced-resolve + jiti + Lightning CSS), and `@tailwindcss/oxide` is the Rust scanner bridged through napi-rs. Six consumer packages (cli, postcss, vite, webpack, turbopack, browser, standalone) sit on top, and `@tailwindcss/upgrade` is a separate codemod CLI. There is no server or database; the storage layer is the filesystem plus in-memory LRU caches.
- `packages/tailwindcss/src/index.ts` exports the three public compiler entrypoints — `compile(css, opts)` (string→string), `compileAst(ast, opts)` (AST→AST, used by the PostCSS plugin), and `__unstable__loadDesignSystem` (used by IntelliSense and the upgrade tool) — plus a default export that throws a hard error if someone wires `tailwindcss` directly into PostCSS.
- The core compiler takes zero filesystem dependencies: `CompileOptions` declares `loadModule` and `loadStylesheet` callbacks that default to `throwOnLoadModule`/`throwOnLoadStylesheet`, which is what makes the same code run in Node, Bun, and the browser CDN build.
- `@tailwindcss/node` is the Node runtime boundary: it wires `enhanced-resolve` resolvers (separate `cssResolver` with `mainFields: ['style']`, `esmResolver`, and `cjsResolver` fallback), `jiti` for `.ts` plugin/config files, and `Module.registerHooks({ resolve: resolveSync })` so ESM config reloads work in watch mode.
- `crates/node/src/lib.rs` is the napi-rs bridge exposing the `Scanner` class (`scan()`, `scanFiles()`, `getCandidatesWithPositions()`, and `files`/`scannedFiles`/`globs`/`normalizedSources` getters) plus the `ChangedContent`, `GlobEntry`, and `SourceEntry` object shapes consumed by every JS integration.
- The Cargo workspace has four crates: `tailwindcss-oxide` (the scanner/extractor), `tailwind-oxide` (`crates/node`, the NAPI shim), `classification-macros` (a `ClassifyBytes` proc-macro deriving byte-class lookup tables for the extractor state machines), and a vendored fork of BurntSushi's `ignore` crate pinned to edition 2024.
- Build is `pnpm build` → `turbo build --filter=!./playgrounds/*`, followed by a `postbuild` hook that runs `scripts/pack-packages.mjs` to `pnpm pack` every non-private workspace into `dist/*.tgz` — the integration tests then install those tarballs.
- Test commands are split three ways: `pnpm test` runs `cargo test && vitest run`, `pnpm test:integrations` runs `vitest --root=./integrations`, and `pnpm test:ui` runs Playwright in both `tailwindcss` and `@tailwindcss/browser`.
- `turbo.json` gives `@tailwindcss/oxide#build` explicit `inputs` covering `../oxide/src/**/*` and `../Cargo.toml`, so touching Rust source correctly busts the Turbo cache for the native addon; it also passes `RUSTUP_HOME` through via `globalPassThroughEnv`.
- Deployment shape is npm publishing with provenance: `@tailwindcss/oxide` fans out into 12 platform-specific optionalDependencies (`napi.targets` includes `wasm32-wasip1-threads`), and `.github/workflows/release.yml` builds each target in a matrix then publishes with `id-token: write` for npm trusted publishing.
- `@tailwindcss/standalone` is built by Bun (`bun ./scripts/build.ts`) into seven single-file executables (linux/macos/windows × arm64/x64, glibc and musl), each SHA-256 checksummed; it is marked `private: true` so `pack-packages.mjs` skips it.
- Lightning CSS is the only output post-processor: `optimize()` in `@tailwindcss/node` runs `transform()` twice (so nesting-flattened adjacent rules get merged), pins browser targets to Safari/iOS 16.4, Firefox 128, Chrome 111, and uses `errorRecovery: true` with a warning filter for framework pseudo-classes like `::ng-deep` and `::v-deep`.
- pnpm patches three dependencies — `@napi-rs/cli`, `@parcel/watcher`, and `lightningcss` — specifically so Bun can statically analyze them when compiling the standalone binary.
Feature catalog
User-facing surface is (a) the CSS authoring language — a set of at-rules parsed in `parseCss`, (b) the `tailwindcss` CLI with `build` and `canonicalize` subcommands, (c) five build-tool integrations, (d) a zero-build browser CDN script, and (e) the `@tailwindcss/upgrade` migration CLI. The compat layer additionally re-exposes the entire v3 JS plugin API (`addUtilities`, `matchVariant`, `theme()`, etc.) so existing plugins keep working. IntelliSense-facing APIs (`getClassList`, `getVariants`, `canonicalizeCandidates`) are first-class exports of the design system rather than an afterthought.
- The CSS at-rule vocabulary handled in `parseCss` is `@tailwind utilities`, `@utility`, `@source` (with `not`, `inline(…)`, and brace-expansion via `expand()`), `@variant`, `@custom-variant`, `@theme` (with `reference`/`inline`/`default`/`static`/`prefix(…)` options), `@apply`, `@import`, `@plugin`, and `@config`.
- `@import "tailwindcss" theme(reference)`, `prefix(ident)`, `important`, and `source(…)` are implemented by rewriting the `@media` node that `postcss-import`-style resolution produces — `@media theme(reference)` is transformed into a `context({ reference: true })` wrapper rather than being a distinct parser path.
- The `tailwindcss` CLI exposes `build` (default when no subcommand is given) and `canonicalize`, with root-level `--help` dispatch in `run()` that also errors on unknown non-flag commands.
- `tailwindcss build` supports `--input/-i` (including `-` for stdin), `--output/-o`, `--watch[=always]`, `--poll[=ms]`, `--minify/-m`, `--optimize`, `--cwd`, `--map`, and `--silent`; when no `--input` is given it defaults to compiling `@import 'tailwindcss';`.
- `tailwindcss canonicalize` rewrites candidate lists to their canonical form, reading from argv or stdin, with `--css` to point at a design-system entry, `--format text|json|jsonl`, and `--stream` for line-by-line stdin processing — aimed at tooling like Prettier plugins and linters.
- `@tailwindcss/vite` ships as an array of three Vite plugins — `@tailwindcss/vite:scan` (config capture), `:generate:serve` (dev), and `:generate:build` (production, which additionally runs `optimize()`) — each with `transform.filter` id include/exclude regexes that skip `?raw`, `?url`, `?worker`, and CommonJS proxy requests.
- `@tailwindcss/postcss` accepts `base`, `optimize` (defaults to `NODE_ENV === 'production'`), and `transformAssetUrls`, and bundles a `fixRelativePathsPlugin()` that repairs paths when `postcss-import` ran before it.
- `@tailwindcss/webpack` and `@tailwindcss/turbopack` are near-identical webpack `LoaderContext` loaders taking `{ base, optimize }`, both keyed into a shared `QuickLRU` cache of max size 50 via `getCacheKey(resourceId, opts)`.
- `@tailwindcss/browser` is a CDN build that reads `<style type="text/tailwindcss">` blocks, auto-injects `@import "tailwindcss";` when the user's CSS has no `@import`, and serves `tailwindcss`, `/preflight`, `/theme`, and `/utilities` from inlined `assets.css.*` strings; plugins and config files throw.
- The v3 JS plugin API is fully re-implemented in `compat/plugin-api.ts`: `addBase`, `addVariant`, `matchVariant`, `addUtilities`, `matchUtilities`, `addComponents`/`matchComponents` (which just delegate to the utility variants), and a `theme()` function built by `createThemeFn`.
- `tailwindcss` re-exports v3 compatibility entry points as separate package exports: `tailwindcss/colors`, `tailwindcss/defaultTheme`, `tailwindcss/plugin`, and `tailwindcss/lib/util/flattenColorPalette`, each with paired `.ts`/`.cts` implementations for ESM and CJS.
- `@tailwindcss/upgrade` is a standalone migration CLI (`--config`, `--force`, `--version`) that refuses to run on a dirty git tree, migrates stylesheets, migrates `tailwind.config.js` into CSS, rewrites class names in templates, and then shells out to the detected package manager to bump `tailwindcss` and friends to `@latest`.
Data flow
There are two independent input streams that converge at `build()`. Stream one is CSS: a stylesheet string is parsed by `CSS.parse`, `@import`s are inlined, at-rules are walked to populate a `Theme`, a `Utilities` registry, and a `Variants` registry, and the result is a `DesignSystem`. Stream two is candidates: the Rust `Scanner` walks the filesystem (gitignore-aware), pre-processes files by extension, runs byte-level state machines to extract class-name-shaped strings, and returns a sorted `Vec<String>` across the NAPI boundary. `build(candidates)` compiles those strings against the design system into AST nodes, splices them into the `@tailwind utilities` placeholder node, runs `optimizeAst`, and prints CSS via `toCss`. State ownership is explicit: `Theme` owns variables, `DesignSystem` owns caches, and the Rust `Scanner` owns file/mtime/candidate sets.
- Input boundary one: `compile(css, opts)` calls `CSS.parse(css, { from })` then `parseCss`, which first wraps the AST in `contextNode({ base }, ast)` and resolves `@import`s through `substituteAtImports` before any at-rule walking happens.
- `@theme` declarations are drained into a `Theme` map keyed by unescaped custom-property name, carrying `ThemeOptions` bit flags (`INLINE`, `REFERENCE`, `DEFAULT`, `STATIC`, `USED`) and a source location; `@keyframes` inside `@theme` are pulled out into `theme.addKeyframes` and re-hoisted as `atRoot` nodes.
- The first `@theme` rule is replaced by a `:root, :host` style rule whose body is filled in *after* the compat hooks run, so JS-config theme extensions land in the same emitted variable block.
- `@source` at-rules are split at parse time: `inline(...)` forms are brace-expanded into `inlineCandidates` (or `ignoredCandidates` when negated, which seed `designSystem.invalidCandidates`), while path forms become `{ base, pattern, negated }` entries returned as `compiler.sources`.
- Every integration builds the scanner source list with the same three-branch rule: `root === 'none'` → no sources, `root === null` → `{ base, pattern: '**/*' }`, else the explicit `source(…)` root, concatenated with `compiler.sources`.
- Input boundary two: `Scanner::scan()` calls `discover_sources()` (a `WalkBuilder` walk that populates `files`, `dirs`, `extensions`, and `mtimes`), then `extract_candidates()`, and returns a `par_sort_unstable`-sorted candidate vector.
- File bytes are transformed before extraction: `pre_process_input(content, extension)` rewrites dialect-specific syntax (e.g. Pug/Haml/Slim shorthand) into something the generic extractor can read, then `Extractor::extract` runs a `CssVariableMachine` and a `CandidateMachine` over the bytes, emitting `Extracted::Candidate` and `Extracted::CssVariable`.
- CSS files found during the walk are routed separately: `extract_candidates` calls `extract_css_variables` on them so that `--my-var` references in stylesheets count as used variables, distinct from utility candidates.
- Candidate strings beginning with `--` are not compiled as utilities at all — `build()` routes them to `designSystem.theme.markUsedVariable(candidate)`, which is how theme variables referenced only from markup survive tree-shaking.
- `compileCandidates` parses each raw candidate into `Candidate[]`, compiles each to AST nodes via `designSystem.compileAstNodes`, and sorts the result by a BigInt variant bitmask first, then by `GLOBAL_PROPERTY_ORDER` index, then by property count, then alphabetically — this is what produces Tailwind's stable output ordering.
- Output boundary: `build()` mutates `utilitiesNode.nodes` in place, runs `optimizeAst(ast, designSystem, opts.polyfills)`, and `compile()`'s wrapper only re-runs `toCss` when the returned AST object identity changed — an explicit identity-based memoization.
- Polyfills are a caller-controlled bitfield (`Polyfills.AtProperty`, `Polyfills.ColorMix`, `Polyfills.All`); the PostCSS plugin masks out `AtProperty` for `.module.css` files because the emitted global `*` rules break CSS Modules purity checks.
Behavior traces
Three flows matter most: a cold CLI build, a CLI watch-mode rebuild, and the Vite dev-server transform. All three share the same skeleton — create compiler, derive sources, construct `Scanner`, scan, build, write — but differ in how they decide between a full and an incremental rebuild. The browser CDN build is a fourth, MutationObserver-driven variant of the same loop. The distinguishing detail across all of them is that 'full rebuild' means *discarding and recreating the compiler*, while 'incremental' means only feeding new candidate strings into the existing one.
- Cold CLI build order: `run()` → `build.handle(flags)` → resolve `--cwd`/`--input`/`--output` to absolute paths and reject identical in/out → read input (or `drainStdin()`, or default `@import 'tailwindcss';`) → `createCompiler(input, I)` → `scanner.scan()` → `compiler.build(candidates)` → `write(output, map, args, I)` → print `Done in …`.
- `createCompiler` registers an `onDependency(path)` callback that pushes into `fullRebuildPaths`, and then deliberately appends a *negated* source for `path.basename(process.execPath)` so the standalone binary never scans itself — a rename-proof self-exclusion.
- Watch mode calls `loadWatcher()` first (so a missing `@parcel/watcher` fails with a useful message before any watcher is registered), then `createWatchers(await watchDirectories(scanner), handle)`.
- The watch handler short-circuits when the only changed file is `args['--output']`, preventing the write-triggers-rebuild infinite loop, then calls `getRebuildStrategy(files, fullRebuildPaths)` to choose a branch.
- On a `full` rebuild the CLI does, in order: re-read input → `clearRequireCache(resolvedFullRebuildPaths)` → snapshot `backupRebuildPaths` → reset `fullRebuildPaths` → `createCompiler` → `scanner.scan()` → set up new watchers → tear down old watchers → `compiler.build(candidates)`.
- On an `incremental` rebuild it calls `scanner.scanFiles(rebuildStrategy.changedFiles)` and returns early without writing if zero new candidates were found — so touching a file with no new classes costs no disk write.
- The watch `catch` block restores `fullRebuildPaths = backupRebuildPaths` before logging, with an inline comment explaining the recovery case: deleting a transitive dependency of `tailwind.config.js` would otherwise leave the compiler permanently unable to re-register that dependency.
- Polling mode is a separate code path from event-based watching: `--poll` is validated to be a positive number (default 250ms), and it drives a `fullRebuild(I)` closure plus cursor-hiding escape codes with `SIGINT`/`SIGTERM` handlers that exit 130/143.
- Vite dev transform order: `isPotentialCssRootFile(id)` gate → look up a `Root` in `rootsByEnv` keyed by `this.environment?.name` → `root.generate(src, addWatchFile, I)` → return `{ code, map }`; returning `false` from `generate` deletes the root from the map so it can be garbage collected.
- `Root.generate` decides to rebuild by calling `requiresBuild()`, which stats every entry in `buildDependencies` and returns true if any mtime advanced or any file is missing — an mtime-map strategy mirrored in the PostCSS and webpack plugins.
- After compiling, `Root.generate` bails with `false` unless the compiler reported at least one of `Features.AtApply | JsPluginCompat | ThemeFunction | Utilities | Variants` — this is how ordinary non-Tailwind `.css` files pass through Vite untouched.
- The PostCSS plugin has an even cheaper bail: before touching the cache it does a `root.walkAtRules` scan for any of `import|reference|theme|variant|config|plugin|apply|tailwind` and returns immediately if none are present.
Testing signals
Testing is unusually thorough and layered: Vitest for ~80 co-located unit/snapshot suites, `cargo test` for the Rust scanner and the vendored `ignore` crate, a bespoke Vitest-based integration harness that materializes real temp projects and installs packed tarballs, and Playwright for browser-truth assertions about computed styles. CI runs unit and integration suites as two separate workflows, each with a three-OS matrix that is trimmed to Linux-only on feature branches unless the PR body contains `[ci-all]`. The gaps are mostly in the thin adapter layers — the CLI watch/poll machinery, the webpack/Turbopack loaders, and the release scripts have no direct unit tests.
- Vitest is configured at the root with `projects: ['./packages/*']` and excludes `*.spec.*` files and `integrations/**` — the `.spec.ts` extension is reserved for Playwright so the two runners never collide.
- Roughly 80 `*.test.ts` files live beside their sources under `packages/`, dominated by `utilities.test.ts` at ~31,600 lines and `index.test.ts` at ~7,300 lines — the utility matrix is tested exhaustively by snapshot.
- `packages/tailwindcss/vitest.config.mts` registers a custom snapshot serializer at `src/test-utils/custom-serializer.ts`, which is why CSS snapshots read as formatted stylesheets rather than escaped strings.
- Rust coverage centers on `crates/oxide/tests/scanner.rs` (~4,080 lines, 80+ `#[test]` cases inside a `mod scanner` block) covering source resolution, gitignore interaction, and symlink handling; the vendored `ignore` crate carries its own upstream tests.
- The integration harness in `integrations/utils.ts` exports a custom `test(name, config, cb)` that mkdtemps a real project directory, writes a `fs` map of files, and hands the callback `exec`, `spawn`, `fs.expectFileToContain`, and `parseSourceMap` helpers.
- Integration tests run against real packed tarballs: `PUBLIC_PACKAGES` is derived by reading `dist/` and mapping `tailwindcss-*.tgz` back to `@tailwindcss/*` names, which is why `pnpm build` must precede `pnpm test:integrations`.
- There are 42 integration test files across six suites — `cli`, `oxide`, `postcss`, `turbopack`, `upgrade`, `vite`, `webpack` — with the Vite suite alone covering Astro, Nuxt, SvelteKit, SolidStart, Qwik, React Router, Preact, Vue, CSS Modules, SSR, and source maps.
- The integration harness compensates for known CI flakiness: timeouts are 120s on Windows vs 60s elsewhere, `retry` defaults to 2 under CI, and on Windows CI the temp root is derived from `GITHUB_WORKSPACE` because `tmpdir()` returns a `RUNNER~1` short path that breaks Vite builds.
- Playwright covers browser-truth behavior that unit tests cannot: `packages/tailwindcss/tests/ui.spec.ts` (~2,480 lines) asserts *computed* values such as `touch-action` after `page.hover()` and resolved `linear-gradient(... in oklab ...)` strings.
- The Playwright config sets Firefox `ui.primaryPointerCapabilities`/`ui.allPointerCapabilities` to `0x02 | 0x04` because headless Firefox otherwise reports no pointer, which would silently break every `hover:` variant assertion relying on `(hover: hover)`.
- WebKit is skipped on Windows in both Playwright configs (a hanging-worker workaround) and the CI matrix pins macOS to `macos-15` for the unit job because Playwright 1.62+ dropped WebKit on macOS 14 and hangs rather than failing.
- CI has two independent workflows: `ci.yml` (build → lint → `pnpm test` → Playwright) and `integration-tests.yml` (a 6-way `integration` matrix crossed with the OS matrix), both notifying Discord via `secrets.DISCORD_WEBHOOK_URL` on `main` failures.
Risk map
Security surface is narrow but real: the compiler loads and executes arbitrary user JS via `@plugin`/`@config` through jiti, the upgrade CLI shells out to git and the package manager and rewrites source files in place, and the scanner walks the filesystem outside the repo root whenever `@source` points there. The larger operational risks are correctness-of-caching risks — every integration hand-rolls its own mtime-based invalidation, and the core `build()` explicitly assumes candidates are only ever added, never removed. Cross-platform fragility (Windows paths, symlinks, Bun/USERPROFILE) shows up repeatedly as inline workarounds, which is a reliable signal of where regressions land.
- `@plugin` and `@config` execute arbitrary user code: `loadModule` resolves the id and calls `importModule`, which falls back to `createJiti(...).import(path)` when native `import()` fails — so a `.ts` config file is transpiled and run in-process with full Node privileges.
- `globalThis.__tw_resolve` and `globalThis.__tw_load` are ambient global escape hatches consulted before every CSS and JS resolution; the standalone binary installs them to serve embedded assets, but any code loaded into the process can override module resolution by setting them.
- `@tailwindcss/upgrade` shells out with `execSync` to `git rev-parse --show-toplevel`, `git status --porcelain`, and `npm ls tailwindcss --json`, then calls `pkgManager.add(...)` to install packages — it mutates the user's dependency tree as part of a 'migration'.
- The upgrade tool's only safety net for in-place source rewriting is the `isRepoDirty()` check, which `--force` disables outright; after that it rewrites every matched CSS file and every template file via `writeFileSafely` with no backup.
- Template rewriting is heuristic and acknowledged as such: `isSafeMigration` maintains hand-written regex denylists for Vue `v-if`, Alpine `wire:`, shadcn `variant:` props, Next.js `placeholder=`, and `$emit(` — any unlisted pattern risks a class-name rewrite inside a string that is not a class attribute.
- File IO reaches outside the repo root by design: `SourceEntry::External` exists specifically to let `@source "../node_modules/my-lib"` bypass the default ignore rules, and `resolveJsId` extends module search with `process.env.NODE_PATH`.
- `build()` carries a documented soundness assumption — 'this currently assumes that we only add new candidates and never remove any' — appearing twice as an early-return condition, so a candidate disappearing from source will not shrink the emitted CSS without a full rebuild.
- Cache invalidation is duplicated four times with slightly different shapes: `Root.requiresBuild()` in Vite, the `context.mtimes` loop in PostCSS, `getRebuildStrategy` in the CLI, and per-loader `CacheEntry.mtimes` in webpack/Turbopack — a bug fixed in one will not propagate to the others.
- `@tailwindcss/webpack` and `@tailwindcss/turbopack` are near-verbatim copies of each other down to identical `LoaderOptions`, `CacheEntry`, `getCacheKey`, and `getContextFromCache` definitions — divergence between them is a live duplication hazard.
- The PostCSS plugin deliberately swallows errors — it `console.error`s and then throws `root.error(...)` after resetting `context.compiler = null`, with a comment that throwing directly 'will cause PostCSS to no longer watch for changes' — so genuine compile failures can surface as an empty stylesheet plus console noise.
- The Vite plugin carries two hardcoded upstream-bug workarounds — skipping `addWatchFile` for the input file itself (Qwik) and ignoring `.svg` paths containing `#` or `?` (tailwindcss#16877) — meaning edits to those files silently do not trigger rebuilds.
- Windows and symlink handling is a recurring fragility: the scanner canonicalizes via `dunce::canonicalize` with a cached parent-directory map, inserts both symlinked and canonical paths into `self.files`, and `scan_content` has to re-walk the tree to validate previously-unknown changed files.
Extension points
The system has three distinct extension tiers. Tier one is CSS-native: `@utility`, `@custom-variant`, and `@theme` register directly into the design system with no JS. Tier two is the JS plugin API, preserved wholesale from v3 in `compat/plugin-api.ts` and loaded via `@plugin`. Tier three is internal: the `Utilities` and `Variants` classes are plain registries with `static`/`functional` methods that core code and plugins call identically, and the upgrade tool has its own ordered `DEFAULT_MIGRATIONS` array. Adding a new template language to the scanner is a matter of one new pre-processor module plus one match arm.
- `Utilities` is the utility registry — `static(name, compileFn)` and `functional(name, compileFn, options)` push into a `DefaultMap<string, Utility[]>`, with a parallel `completions` map driving IntelliSense suggestions.
- `Variants` is the variant registry, offering `static`, `functional`, and `fromAst(name, ast, designSystem)` (which powers `@custom-variant` with a body), and tracking `compounds`/`compoundsWith` bit flags that decide which variants may be composed with which.
- `@utility` is the CSS-native extension point: `createCssUtility(node)` turns the at-rule into a `(designSystem) => void` registration function, with explicit validation that functional utilities end in `-*` and that `@utility` is never nested.
- `@custom-variant` registrations are topologically sorted before application via `topologicalSort(customVariantDependencies, { onCircularDependency })`, so variants may reference each other and circular references produce a rendered CSS error rather than a stack overflow.
- `buildPluginApi` is the JS plugin extension surface, exposing `addBase`, `addVariant`, `matchVariant`, `addUtilities`, `matchUtilities`, `addComponents`, `matchComponents`, and a `theme()` accessor, with name validation that rejects non-single-class selectors and non-lowercase-leading utility names.
- `@plugin` supports typed options passed as a declaration body — `CssPluginOptions` values are parsed from the declaration text into JSON primitives (`null`, `true`, numbers, comma-separated lists) before being handed to the plugin factory.
- `applyCompatibilityHooks` is the single seam where all v3 behavior is grafted on — it registers `darkModePlugin`, `registerContainerCompat`, `registerLegacyUtilities`, `registerScreensConfig`, and `registerThemeVariantOverrides`, keeping legacy concerns out of core.
- `DesignSystem.storage` is an explicit general-purpose extension slot — a `Record<symbol, unknown>` documented as requiring unique symbol keys to avoid collisions; the canonicalization code uses it via `prepareDesignSystemStorage` and `UTILITY_SIGNATURE_KEY`.
- Adding a new template language to the scanner requires two edits: a new module in `crates/oxide/src/extractor/pre_processors/` re-exported from `mod.rs`, and a new extension arm in the `pre_process_input` match statement.
- The extractor is itself a pluggable set of state machines all implementing the `Machine` trait — `ArbitraryPropertyMachine`, `ArbitraryValueMachine`, `NamedUtilityMachine`, `NamedVariantMachine`, `ModifierMachine`, `StringMachine`, and others — which `main.rs` can benchmark individually via `_run_machine::<T>`.
- The upgrade tool's template codemods are an ordered array `DEFAULT_MIGRATIONS: Migration[]` where each entry is `(designSystem, userConfig, rawCandidate) => string`; order is load-bearing, flagged by the inline comment that `migrateVariantOrder` must run before variant-modifying migrations.
- CSS-level codemods are a parallel, separately-composed pipeline in `codemods/css/` (`migrate-at-apply`, `migrate-at-layer-utilities`, `migrate-import`, `migrate-media-screen`, `migrate-preflight`, `migrate-tailwind-directives`, `migrate-theme-to-var`, `migrate-variants-directive`), each with a co-located test file.
Contributor handoff
Read the core compiler before touching anything: `packages/tailwindcss/src/index.ts` (`parseCss` → `compileAst` → `compile`), then `design-system.ts` and `compile.ts`. Almost every behavioral change belongs in `packages/tailwindcss` or `crates/oxide`, not in the integration packages — those are thin and near-duplicative on purpose. Conventions are enforced mechanically: Prettier config lives inline in the root `package.json` (no semicolons, single quotes, 100 columns, organize-imports), and `pnpm lint` runs `prettier --check` plus `turbo lint` (which is `tsc --noEmit` per package). The project explicitly does not accept feature PRs without prior discussion, and does not accept any PR without tests.
- Read first, in order: `packages/tailwindcss/src/index.ts` (the at-rule walk and `build()` contract), `packages/tailwindcss/src/design-system.ts` (the `DesignSystem` type and its caches), `packages/tailwindcss/src/compile.ts` (candidate → AST + sorting), and `crates/oxide/src/scanner/mod.rs` (the scan lifecycle).
- Setup is `pnpm install`, `rustup default stable`, `rustup target add wasm32-wasip1-threads`, then `pnpm build`; the WASM target is mandatory because `@tailwindcss/oxide`'s `build` script always runs `build:wasm` after `build:platform`.
- Day-to-day loop is `pnpm tdd` (Vitest watch with `--hideSkippedTests`); for browser or integration behavior you must run `pnpm build` first because both harnesses consume built artifacts.
- Before submitting: run `pnpm test` (cargo + vitest), `pnpm build && pnpm test:integrations`, `pnpm build && pnpm test:ui`, and `pnpm run lint`; `pnpm run format` auto-fixes style.
- Code style is Prettier with `semi: false`, `singleQuote: true`, `printWidth: 100`, and `prettier-plugin-organize-imports`; `integrations/**/*.ts` additionally uses `prettier-plugin-embed` so embedded CSS/HTML template literals are formatted.
- Safe edit boundary: the core compiler must stay IO-free. Anything needing `fs`, module resolution, or Lightning CSS belongs in `@tailwindcss/node`, which is the only package allowed to be the bridge between core and the runtime.
- A change to utility or variant output almost always needs a snapshot update in `packages/tailwindcss/src/utilities.test.ts` or `variants.test.ts`, plus a check of `packages/tailwindcss/src/__snapshots__/`; these files are enormous, so target specific test names rather than running the whole suite.
- Trap: output ordering is not alphabetical. It is governed by `GLOBAL_PROPERTY_ORDER` in `property-order.ts` combined with a BigInt variant bitmask; reordering that array silently reshuffles every generated stylesheet.
- Trap: `process.env.NODE_ENV !== 'test'` gates both the license banner comment prepended in `compileAst` and the Lightning CSS warning output, so CSS produced under test differs from production output.
- Trap: the integration packages duplicate the same source-derivation block (`root === 'none'` / `root === null` / explicit root, concat `compiler.sources`). Changing that contract in core requires updating the CLI, Vite, PostCSS, webpack, and Turbopack call sites together.
- Trap: Rust edits need `pnpm build` (or `pnpm --filter @tailwindcss/oxide build`) to regenerate the `.node` addon before JS tests see them; `turbo.json` declares the cross-crate `inputs` that make this cache correctly.
- Debugging hook: set `DEBUG=1` (or `DEBUG=tailwindcss`) to enable the `Instrumentation` timers that bracket every phase — 'Setup compiler', 'Scan for candidates', 'Build CSS', 'Optimize CSS' — across the CLI, Vite, PostCSS, and loaders.
Languages
| Language | Files | Share of source |
|---|---|---|
| TypeScript | 307 | 78% |
| Rust | 61 | 16% |
| JSON | 47 | docs/config |
| Markdown | 29 | docs/config |
| CSS | 18 | docs/config |
| JavaScript | 16 | 4% |
| TOML | 9 | 2% |
| YAML | 7 | docs/config |
Start reading here
Probable source entrypoint: Cargo.toml — inferred from the directory tree and manifest, not the upstream docs.
Source roots
packages/ (323 files) crates/ (116 files)
Source layout — packages/
| Path | Files |
|---|---|
packages/@tailwindcss-browser/ | 11 |
packages/@tailwindcss-cli/ | 18 |
packages/@tailwindcss-node/ | 20 |
packages/@tailwindcss-postcss/ | 23 |
packages/@tailwindcss-standalone/ | 5 |
packages/@tailwindcss-turbopack/ | 6 |
packages/@tailwindcss-webpack/ | 6 |
packages/tailwindcss/ | 11 |
packages/tsconfig.base.json | 1 |
Repository structure
| Top-level path | Files |
|---|---|
packages/ | 323 |
crates/ | 116 |
integrations/ | 45 |
playgrounds/ | 31 |
.github/ | 12 |
scripts/ | 6 |
patches/ | 3 |
Docs found: README.md
Package
Cargo.toml — , 0 declared dependencies.
CI & config entrypoints
README.md— Repository readmecrates/oxide/src/main.rs— Rust binary entrypointplaygrounds/vite/vite.config.ts— Vite configplaygrounds/vite/src/main.tsx— Vite React entrypointpackages/@tailwindcss-browser/playwright.config.ts— Playwright e2e configpackages/tailwindcss/playwright.config.ts— Playwright e2e config.github/workflows/release.yml— GitHub Actions workflow.github/workflows/integration-tests.yml— GitHub Actions workflow.github/workflows/prepare-release.yml— GitHub Actions workflow.github/workflows/ci.yml— GitHub Actions workflow
Structural health signals
Average score 7.1/10 across 96 analyzed source files; 28 hotspots surfaced.
crates/ignore/src/incremental.rs— 1286 lines, score 4 (Large file, Deep nesting)crates/ignore/src/gitignore.rs— 913 lines, score 4 (Large file, Deep nesting)crates/oxide/src/glob.rs— 601 lines, score 4.5 (Large file, Deep nesting)
These are deterministic review leads — size, churn, and structural signals — not proof of a bug in this project.
Recent history
41d9cae8e5332026-09-08 — Reject candidates with multiple modifiers (#20466)f723e834ad032026-08-31 — Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS (#20434)9f451eead86b2026-08-31 — Cold start performance improvements (#20427)90f8ff41c8e22026-08-15 — Don't space out `and`/`or`/`not` inside function calls in `supports-[…]` variants (#20420)7a7f386648212026-08-14 — Don't emit utilities that resolve a theme value when an unsupported modifier is used (#20419)