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.

  • Frontend
  • $tailwind-v4
  • 178 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated
  • Editor's pick

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- tailwind-v4

Installs to ~/.agents/skills/tailwind-v4/ for every project on this machine.

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

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.

~/.agents/skills/tailwind-v4/SKILL.md

--- name: tailwind-v4 description: Sets up and migrates Tailwind CSS v4 with CSS-first configuration - @import "tailwindcss", @theme variables and namespaces, @source for content… license: MIT ---

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

  1. Read AGENTS.md, package.json (tailwindcss version, build tool), and find the entry CSS (rg -l "@tailwind|@import \"tailwindcss\"" --glob '*.css').
  2. Read tailwind.config.* if present: custom theme, plugins, content, safelist, darkMode, prefix, important.
  3. 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.
  4. 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 toolPackageWiring
Vite (incl. SvelteKit, Nuxt via Vite, Astro, React Router)@tailwindcss/viteplugins: [tailwindcss()] in the Vite config
Next.js, other PostCSS pipelines@tailwindcss/postcsspostcss.config.mjs: { plugins: { "@tailwindcss/postcss": {} } }
No bundler@tailwindcss/clinpx @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:

NamespaceGenerates
--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 a next/font variable), so utilities resolve at use site.
  • Use theme values in custom CSS as var(--color-brand-500), not theme().
  • Read tokens in JS with getComputedStyle(document.documentElement).getPropertyValue("--color-brand-500"); resolveConfig no 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); }
}
  • @utility replaces @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, @apply needs the theme: @reference "../app.css"; at the top (it imports definitions without emitting CSS).
  • Classes must appear as complete strings in source. bg-${color}-500 is never generated; map to full class names or use @source inline("bg-red-500 bg-green-500") (v4.1+), which replaces safelist.

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.

v3v4
shadow-sm / shadowshadow-xs / shadow-sm (same for drop-shadow, blur, backdrop-blur, rounded)
ring (3px, blue)ring-3; bare ring is 1px currentColor
outline-noneoutline-hidden (v4 outline-none sets outline-style: none)
border, divide-* default gray-200default currentColor; add explicit colors
bg-opacity-50bg-black/50
flex-shrink-0, flex-growshrink-0, grow
!font-boldfont-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”.
  • @theme must be top level, not nested in selectors or media queries. Use plain CSS variables under .dark / [data-theme] for runtime theming, and point @theme inline tokens at them.
  • OKLCH colors render differently from the v3 hex palette in a few shades; compare brand colors visually and pin hex values in @theme if 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.yamlgenerated from this page

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.py187 lines, 7.8 KB
#!/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.

esc