Tailwind CSS v4 skill for Codex
Config in CSS, upgrade without visual drift. Codex configures Tailwind v4 in CSS with @theme namespaces, @source, @utility and @custom-variant instead of a JS config, and runs v3 upgrades as a measured process: inventory with the bundled scanner, run the official upgrade tool, then rescan for leftovers and fix the border, ring, shadow and hover behaviour changes that quietly alter the UI.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- tailwind-v4 Installs to ~/.agents/skills/tailwind-v4/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install tailwind-v4 The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project tailwind-v4 Installs to .agents/skills/tailwind-v4/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/tailwind-v4.zipunzip -o tailwind-v4.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) tailwind-v4 PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $tailwind-v4, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up and migrates Tailwind CSS v4 with CSS-first configuration - @import "tailwindcss", @theme variables and namespaces, @source for content detection, @utility and @custom-variant, @plugin and @config for legacy setups, the Vite and PostCSS plugins, and the v3 to v4 upgrade including renamed shadow, rounded, blur and ring scales. Use when installing Tailwind v4, moving tailwind.config.js into CSS, running @tailwindcss/upgrade, adding design tokens or dark mode, or fixing missing classes and changed borders, rings and shadows after an upgrade.
Use it when
- Installing Tailwind v4 in a Vite, Next.js or plain CSS project
- Migrating a v3 config, plugins and templates to v4
- Adding design tokens, custom utilities or class-based dark mode
- Classes are missing or borders and shadows changed after upgrading
Use something else when
- Deciding the token system itself, names and scales: use design-tokens
- Structuring non-utility CSS, layers and naming: use css-architecture
How to check the result
The production build has no unknown-utility errors, scan_v3_classes.py exits 0 on the source tree, and screenshots of key pages match the pre-upgrade baseline.
The mistake it prevents
Trusting the upgrade tool alone: bare border and ring now default to currentColor, so cards gain dark outlines nobody notices until production.
Done well means: configuration lives in the main CSS file as @theme variables, there is
no tailwind.config.js unless a plugin truly needs it, every class used in the source is
generated (no silent misses from content detection), and the upgraded UI matches the v3
UI pixel for pixel where nobody asked for a change.
Inspect first
- Read
AGENTS.md,package.json(tailwindcssversion, build tool), and find the entry CSS (rg -l "@tailwind|@import \"tailwindcss\"" --glob '*.css'). - Read
tailwind.config.*if present: custom theme, plugins,content,safelist,darkMode,prefix,important. - Check browser support requirements. v4 targets Safari 16.4+, Chrome 111+, Firefox
128+ (it relies on
@property,color-mix()and cascade layers). If the product must support older browsers, stay on v3.4 and say so. - Check for Sass/Less/Stylus: v4 is not designed to run alongside preprocessors. Plan to move those styles to plain CSS (nesting is supported natively).
Install
| Build tool | Package | Wiring |
|---|---|---|
| Vite (incl. SvelteKit, Nuxt via Vite, Astro, React Router) | @tailwindcss/vite | plugins: [tailwindcss()] in the Vite config |
| Next.js, other PostCSS pipelines | @tailwindcss/postcss | postcss.config.mjs: { plugins: { "@tailwindcss/postcss": {} } } |
| No bundler | @tailwindcss/cli | npx @tailwindcss/cli -i src/app.css -o dist/app.css --watch |
Remove autoprefixer and postcss-import from the PostCSS config; v4 handles both.
Installing packages needs network; ask for approval in a sandbox without it.
CSS-first configuration
/* src/app.css */
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "../node_modules/@acme/ui/dist"; /* scan a package that ships classes */
@source not "../src/legacy"; /* skip a folder */
@custom-variant dark (&:where(.dark, .dark *)); /* class-based dark mode */
@theme {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-500: oklch(0.62 0.17 250);
--color-brand-900: oklch(0.32 0.09 250);
--breakpoint-3xl: 120rem;
--radius-card: 0.875rem;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.06);
--animate-fade-in: fade-in 150ms ease-out;
@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }
}
@utility content-auto {
content-visibility: auto;
}
Each @theme variable creates utilities from its namespace and is also a real CSS
variable on :root:
| Namespace | Generates |
|---|---|
--color-* | bg-*, text-*, border-*, fill-*, … |
--font-*, --text-*, --font-weight-*, --leading-*, --tracking-* | typography |
--spacing (single base value) | every spacing utility as a multiple: p-13, w-17 |
--breakpoint-* | responsive variants (3xl:) |
--container-* | max-w-* and @container sizes |
--radius-*, --shadow-*, --inset-shadow-*, --drop-shadow-*, --blur-* | effects |
--ease-*, --animate-* | motion |
- Replace a whole default scale:
--color-*: initial;then define your own. @theme inline { --font-sans: var(--font-inter); }when the value references another variable (for example anext/fontvariable), so utilities resolve at use site.- Use theme values in custom CSS as
var(--color-brand-500), nottheme(). - Read tokens in JS with
getComputedStyle(document.documentElement).getPropertyValue("--color-brand-500");resolveConfigno longer exists.
Custom utilities and variants
@utility tab-* {
tab-size: --value(integer);
}
@custom-variant hocus (&:hover, &:focus-visible);
.prose-callout {
@apply rounded-card p-4;
@variant dark { background: var(--color-brand-900); }
}
@utilityreplaces@layer utilities { .x {} }; utilities defined this way work with every variant (hover:tab-4,md:content-auto).- In Vue/Svelte
<style>blocks or CSS modules,@applyneeds the theme:@reference "../app.css";at the top (it imports definitions without emitting CSS). - Classes must appear as complete strings in source.
bg-${color}-500is never generated; map to full class names or use@source inline("bg-red-500 bg-green-500")(v4.1+), which replacessafelist.
Upgrading from v3
- [ ] Clean git branch; screenshot key pages (or run visual tests) as the baseline
- [ ] python3 scripts/scan_v3_classes.py --inventory src # size the work
- [ ] npx @tailwindcss/upgrade # Node 20+, needs network
- [ ] Review the diff: config moved into CSS, classes renamed, deps updated
- [ ] python3 scripts/scan_v3_classes.py src # leftovers; must exit 0
- [ ] Fix border/ring/divide colors and other behaviour changes (table below)
- [ ] Build, compare screenshots, fix regressions
scripts/scan_v3_classes.py (standard library only) reports v4-invalid leftovers in its
default mode (opacity utilities, flex-shrink, @tailwind directives, bg-[--var]) and
exits 1 if any exist. --inventory also lists renamed scale classes; use it only before
the upgrade, since afterwards names like shadow-sm are valid with new values. Add
--json for machine-readable output.
The upgrade tool renames classes in templates it can find. It cannot see classes built in strings outside class attributes or in a CMS. Search for those by hand.
| v3 | v4 |
|---|---|
shadow-sm / shadow | shadow-xs / shadow-sm (same for drop-shadow, blur, backdrop-blur, rounded) |
ring (3px, blue) | ring-3; bare ring is 1px currentColor |
outline-none | outline-hidden (v4 outline-none sets outline-style: none) |
border, divide-* default gray-200 | default currentColor; add explicit colors |
bg-opacity-50 | bg-black/50 |
flex-shrink-0, flex-grow | shrink-0, grow |
!font-bold | font-bold! (leading ! still parses, deprecated) |
bg-[--brand] | bg-(--brand) |
@tailwind base; ... | @import "tailwindcss"; |
@layer utilities { .x {} } | @utility x {} |
darkMode: "class" | @custom-variant dark (...) |
safelist | @source inline(...) |
prefix: "tw-" | @import "tailwindcss" prefix(tw); and classes tw:flex |
Behaviour changes with no class rename: hover: applies only on devices that support
hover (@media (hover: hover)); space-x/y-* uses a different selector (margin on
:not(:last-child)), which can change layouts with hidden children; stacked variants
apply left to right; buttons default to cursor: default; placeholder text uses the
current color at 50% opacity. Prefer gap-* over space-* when touching layouts.
Keep a JS config only when a plugin requires it: @config "../tailwind.config.js";.
corePlugins and some config options are not supported there.
Gotchas
- Content detection ignores
.gitignored paths and binary files. Classes in a gitignored generated folder or an npm package need@source. - Multiple CSS entry points each need
@import "tailwindcss"or@reference; a file that only@applys without either fails with “Cannot apply unknown utility class”. @thememust be top level, not nested in selectors or media queries. Use plain CSS variables under.dark/[data-theme]for runtime theming, and point@theme inlinetokens at them.- OKLCH colors render differently from the v3 hex palette in a few shades; compare
brand colors visually and pin hex values in
@themeif design requires exact matches.
Verification
- Production build succeeds with no “unknown utility” errors.
python3 scripts/scan_v3_classes.py <src>exits 0 after migration.- Visual comparison of key pages (Playwright screenshots or the repo’s visual tests); borders, rings, shadows and radii are the usual diffs.
rg -n "tailwind.config|autoprefixer" .shows only intentional remnants.
Output
Report: install method, theme tokens added or changed, custom utilities and variants, any remaining JS config and why, scan results, and visual differences accepted.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Tailwind CSS v4"
short_description: "Config in CSS, upgrade without visual drift"
default_prompt: "$tailwind-v4 Upgrade this project from Tailwind 3 to Tailwind 4 and check nothing looks different" scripts/scan_v3_classes.py
#!/usr/bin/env python3
"""Find Tailwind CSS v3-only classes and directives in a source tree.
Two modes:
default Report things that do not exist in Tailwind v4 at all (opacity
utilities, flex-shrink/grow, overflow-ellipsis, @tailwind
directives, bracketed CSS-variable shorthand). Safe to run after
an upgrade: every hit is a real leftover.
--inventory Also report v3 classes whose meaning changed in v4 (shadow-sm,
rounded, bare ring, outline-none, ...). Run this BEFORE upgrading
to size the work; after upgrading these names are valid v4
classes with different values, so hits are expected.
Usage:
python3 scan_v3_classes.py [--inventory] [--json] <dir-or-file> [...]
Exit code: 0 when nothing is found, 1 when findings exist, 2 on bad usage.
Standard library only.
"""
from __future__ import annotations
import json
import os
import re
import sys
CODE_EXT = {".html", ".htm", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue",
".svelte", ".astro", ".mdx", ".md", ".php", ".erb", ".twig", ".liquid",
".hbs", ".njk", ".cshtml", ".razor", ".py", ".rb", ".ex", ".heex"}
CSS_EXT = {".css", ".scss", ".pcss", ".postcss", ".less"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".nuxt", ".output",
".svelte-kit", ".astro", "coverage", "vendor", ".turbo", ".cache"}
# Removed in v4 (any hit is a leftover). (regex on the bare utility, suggestion)
REMOVED = [
(re.compile(r"^(bg|text|border|divide|ring|placeholder)-opacity-\d+$"),
"use an opacity modifier on the color, e.g. bg-black/50"),
(re.compile(r"^flex-shrink(-\d+)?$"), "use shrink / shrink-0"),
(re.compile(r"^flex-grow(-\d+)?$"), "use grow / grow-0"),
(re.compile(r"^overflow-ellipsis$"), "use text-ellipsis"),
(re.compile(r"^decoration-(slice|clone)$"), "use box-decoration-slice / box-decoration-clone"),
(re.compile(r"^[a-z-]+-\[--[A-Za-z0-9_-]+\]$"), "v4 shorthand for CSS variables is parentheses: bg-(--brand)"),
]
# Renamed or changed meaning between v3 and v4 (inventory mode only).
RENAMED = {
"shadow-sm": "shadow-xs", "shadow": "shadow-sm",
"drop-shadow-sm": "drop-shadow-xs", "drop-shadow": "drop-shadow-sm",
"blur-sm": "blur-xs", "blur": "blur-sm",
"backdrop-blur-sm": "backdrop-blur-xs", "backdrop-blur": "backdrop-blur-sm",
"rounded-sm": "rounded-xs", "rounded": "rounded-sm",
"outline-none": "outline-hidden",
"ring": "ring-3 (v4 bare ring is 1px, currentColor)",
}
BEHAVIOUR = {
"border": "default border color is currentColor in v4; add a color (border-gray-200)",
"divide-x": "default divide color is currentColor in v4; add a color",
"divide-y": "default divide color is currentColor in v4; add a color",
}
BARE_AMBIGUOUS = {"shadow", "rounded", "ring", "blur", "border", "drop-shadow", "backdrop-blur"}
CSS_RULES = [
(re.compile(r"@tailwind\s+(base|components|utilities)"), 'replace with @import "tailwindcss";'),
(re.compile(r"\btheme\([^)]*\)"), "prefer var(--color-...) / var(--spacing) theme variables"),
(re.compile(r"@layer\s+utilities\s*\{"), "custom utilities become @utility name { ... } in v4"),
(re.compile(r"@layer\s+components\s*\{"), "v4: keep for component classes, or convert to @utility for variant support"),
]
STRING_RE = re.compile(r"\"([^\"\n]*)\"|'([^'\n]*)'|`([^`]*)`")
TW_HINT = re.compile(
r"^-?(p[xytrbl]?|m[xytrbl]?|w|h|size|min-w|max-w|min-h|max-h|flex|grid|gap|text|bg|"
r"font|leading|tracking|items|justify|self|place|inset|top|left|right|bottom|z|"
r"opacity|transition|duration|ease|space-[xy]|divide|overflow|cursor|col|row|"
r"order|basis|aspect|object|shadow|rounded|ring|border|outline|blur|hidden|block|"
r"inline|absolute|relative|fixed|sticky|truncate|underline|uppercase|sr-only)\b")
def bare(token: str) -> str:
"""Strip variants (hover:, md:, [&>*]:) and important markers from a class token."""
depth, cut = 0, -1
for i, ch in enumerate(token):
if ch == "[":
depth += 1
elif ch == "]":
depth -= 1
elif ch == ":" and depth == 0:
cut = i
t = token[cut + 1:]
return t.strip("!")
def classify(token: str, inventory: bool, string_is_classes: bool):
t = bare(token)
for rx, hint in REMOVED:
if rx.match(t):
return ("removed", hint)
if not inventory:
return None
if t in BARE_AMBIGUOUS and not string_is_classes:
return None
if t in RENAMED:
return ("renamed", f"v3 {t} -> v4 {RENAMED[t]}")
if t in BEHAVIOUR:
return ("behaviour", BEHAVIOUR[t])
if token.startswith("!") and len(token) > 1:
return ("syntax", f"important modifier goes at the end in v4: {token[1:]}!")
return None
def scan_code(path: str, inventory: bool, out: list):
with open(path, encoding="utf-8", errors="replace") as fh:
for lineno, line in enumerate(fh, 1):
for m in STRING_RE.finditer(line):
s = next(g for g in m.groups() if g is not None)
tokens = s.split()
if not tokens:
continue
twish = sum(1 for tk in tokens if TW_HINT.match(bare(tk)))
is_classes = len(tokens) >= 2 and twish >= 2
for tk in tokens:
hit = classify(tk, inventory, is_classes)
if hit:
out.append({"file": path, "line": lineno, "token": tk, "kind": hit[0], "hint": hit[1]})
def scan_css(path: str, inventory: bool, out: list):
with open(path, encoding="utf-8", errors="replace") as fh:
for lineno, line in enumerate(fh, 1):
for rx, hint in CSS_RULES:
if rx.search(line):
out.append({"file": path, "line": lineno, "token": rx.search(line).group(0),
"kind": "css", "hint": hint})
m = re.search(r"@apply\s+([^;]+);", line)
if m:
for tk in m.group(1).split():
hit = classify(tk, inventory, True)
if hit:
out.append({"file": path, "line": lineno, "token": tk, "kind": hit[0], "hint": hit[1]})
def walk(target: str):
if os.path.isfile(target):
yield target
return
for root, dirs, files in os.walk(target):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for f in files:
yield os.path.join(root, f)
def main(argv: list[str]) -> int:
inventory = "--inventory" in argv
as_json = "--json" in argv
targets = [a for a in argv if not a.startswith("--")]
if not targets:
print(__doc__.strip(), file=sys.stderr)
return 2
findings: list = []
files = 0
for t in targets:
if not os.path.exists(t):
print(f"error: {t} does not exist", file=sys.stderr)
return 2
for p in walk(t):
ext = os.path.splitext(p)[1].lower()
if ext in CSS_EXT:
files += 1
scan_css(p, inventory, findings)
elif ext in CODE_EXT:
files += 1
scan_code(p, inventory, findings)
if as_json:
print(json.dumps({"files_scanned": files, "findings": findings}, indent=2))
else:
for f in findings:
print(f"{f['file']}:{f['line']}: [{f['kind']}] {f['token']} -> {f['hint']}")
kinds: dict = {}
for f in findings:
kinds[f["kind"]] = kinds.get(f["kind"], 0) + 1
summary = ", ".join(f"{k}={v}" for k, v in sorted(kinds.items())) or "none"
mode = "inventory" if inventory else "leftovers"
print(f"\nScanned {files} files ({mode} mode). Findings: {len(findings)} ({summary})")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.