tailwind v4

SkillDev tools

Tailwind CSS v4 conventions, dark mode with @variant dark, and CSS-based configuration (no tailwind.config.js). ALWAYS load this skill before editing any Tailwind styles, markup, or CSS in a Tailwind project.

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the tailwind v4 skill

What this skill tells your AI

The instructions your AI receives, as published by remorses/opencode-config in skills/tailwind/SKILL.md and read by ahel’s review.

use tailwind v4. this new tailwind version does not use tailwind.config.js. instead it does all configuration in css files.

read https://tailwindcss.com/docs/upgrade-guide to understand the updates landed in tailwind v4 if you do not have tailwind v4 in your training context. if the project still uses tailwind v3, see migration-v3-to-v4.md for the upgrade steps and auto migration CLI.

Vite projects — use the Tailwind Vite plugin

in Vite projects, always use @tailwindcss/vite instead of the PostCSS plugin. it's faster because it hooks directly into Vite's pipeline and skips the PostCSS layer entirely.

// vite.config.ts
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [tailwindcss()],
})

only fall back to @tailwindcss/postcss for non-Vite setups (Next.js, Webpack, etc.).

design

prefer a minimalistic, Vercel-like design

focus on good spacing, sizes, gaps and consistency. focus on good typography.

do not use Card as a way to group elements. it looks cheap and it's overly used. do not use shadows unless asked to.

instead prefer minimal layouts with good positioning of the elements. few choices but very carefully made to look good.

if there are already components for what you need inside components/ui folder use them. do not re declare components again.

Centered multiline text

Always add text-balance to centered headings, descriptions, and empty-state copy that can wrap across multiple lines. Centered ragged text looks uneven without balancing, especially in hero sections, onboarding cards, dialogs, and dashboard empty states.

<div className="flex flex-col gap-2 text-center text-balance">
  <h1 className="text-2xl font-semibold">Create a docs project</h1>
  <div className="text-sm text-muted-foreground">
    Projects are created by your first deploy. Start from GitHub or the CLI.
  </div>
</div>

dark mode with @variant dark

always prefer @variant dark { ... } over hardcoded .dark selectors for dark mode overrides. this is strategy-agnostic: it compiles to whatever selector is configured in @custom-variant dark (e.g. .dark class, prefers-color-scheme, data-theme, or a combination). changing the strategy only requires updating one line. never write .dark { } or .dark .selector in Tailwind-processed CSS files — use @variant dark instead.

critical: @variant dark must be nested inside a parent CSS rule — it does NOT work at the top level. the & in the custom variant needs a parent selector to bind to. at the top level, it produces a literal &:where(...) that browsers can't resolve.

/* WRONG — & has no parent, produces broken CSS */
@variant dark {
  --background: #111;
}

/* CORRECT — nested inside :root, & binds to :root */
:root {
  --background: #fff;

  @variant dark {
    --background: #111;
  }
}

also critical: @variant dark only works in CSS files processed by Tailwind — the file with @import "tailwindcss" and files @imported from it. CSS files imported via JS import "file.css" are plain CSS and Tailwind directives are silently ignored. for those files, use .dark .selector selectors directly.

utility shorthands

size-* for equal width and height

use size-10 instead of w-10 h-10 when width and height are the same. applies to icons, avatars, skeletons, containers.

<!-- BAD -->
<div class="w-10 h-10">...</div>
<img class="w-8 h-8 rounded-full" />

<!-- GOOD -->
<div class="size-10">...</div>
<img class="size-8 rounded-full" />

truncate shorthand

use truncate instead of overflow-hidden text-ellipsis whitespace-nowrap. single utility does the same thing.

no manual z-index on overlay components

Dialog, Sheet, Drawer, AlertDialog, DropdownMenu, Popover, Tooltip, HoverCard handle their own stacking context. never add z-50 or z-[999] to these components.

styling preferences

always prefer using tailwind for styling. use built-in tailwind colors like gray, red, green, blue, etc.

className for layout, not styling. when using shadcn components, use className only for layout (e.g. max-w-md, mx-auto, mt-4). never override component colors or typography via className. to change appearance, use built-in variants (variant="outline"), semantic tokens (bg-primary), or CSS variables.

no manual dark: color overrides. use semantic tokens that handle light/dark via CSS variables. write bg-background text-foreground not bg-white dark:bg-gray-950. the dark: variant is only acceptable for one-off layout tweaks (e.g. dark:border-opacity-50), never for colors that should come from the theme.

spacing: always prefer gap over margin/padding. use flexbox/grid gap classes for spacing between sibling elements. never use margin-top, margin-bottom, space-y-*, or padding to create space between items in a list or stack. gap is simpler (no first/last-child overrides), composes better, and avoids margin collapse bugs. use py-*/px-* only for internal padding within a single element (e.g. inside a card), not for spacing between siblings.

<!-- BAD — margin between items -->
<div class="flex flex-col">
  <div class="mb-4">Item 1</div>
  <div class="mb-4">Item 2</div>
  <div>Item 3</div>
</div>

<!-- GOOD — gap between items -->
<div class="flex flex-col gap-4">
  <div>Item 1</div>
  <div>Item 2</div>
  <div>Item 3</div>
</div>

CSS custom properties — never duplicate values

never duplicate a CSS variable value. if --ring should match --primary, write --ring: var(--primary), not the same color-mix(...) or oklch(...) expression twice. when adding a new token that derives from an existing one, always reference it with var().

/* WRONG — duplicated expression */
:root {
  --primary: oklch(0.205 0 0);
  --ring: oklch(0.205 0 0);
}

/* CORRECT — reference the source token */
:root {
  --primary: oklch(0.205 0 0);
  --ring: var(--primary);
}

canonical source variables

keep a minimal set of canonical variables that hold actual color values. every other variable must reference one of these canonicals via var(). this means each unique color value exists in exactly one place.

canonical sources (get hardcoded hex/oklch values):

  • --background, --foreground — page surface and text
  • --card, --card-foreground — elevated surface and its text
  • --primary, --primary-foreground — brand color and text on it
  • --secondary, --muted, --muted-foreground, --accent — neutral surfaces
  • --destructive, --success, --warning, --info — semantic status colors
  • --input, --overlay — form/overlay specifics

derived variables (always var() references, never hardcoded):

  • --popover: var(--card) — popovers are elevated surfaces like cards
  • --popover-foreground: var(--card-foreground)
  • --secondary-foreground: var(--card-foreground)
  • --accent-foreground: var(--card-foreground)
  • --destructive-foreground: var(--primary-foreground) — text on status colors is always white
  • --success-foreground: var(--primary-foreground)
  • --warning-foreground: var(--primary-foreground)
  • --info-foreground: var(--primary-foreground)
  • --border: var(--accent) — borders use the accent shade
  • --ring: var(--primary) — focus ring matches brand

derived variables are defined once in :root. they don't need to be repeated in .dark because they resolve from the canonical's dark override automatically. the dark mode block only overrides the canonical sources that actually change value.

:root {
  /* Canonical source variables */
  --background: #ffffff;
  --foreground: #171717;
  --card: #ffffff;
  --card-foreground: #171717;
  --primary: #fa7319;
  --primary-foreground: #ffffff;
  --accent: #ebebeb;

  /* Derived — always reference a canonical */
  --popover: var(--card);
  --popover-foreground: var(--card-foreground);
  --secondary-foreground: var(--card-foreground);
  --accent-foreground: var(--card-foreground);
  --destructive-foreground: var(--primary-foreground);
  --border: var(--accent);
  --ring: var(--primary);
}

.dark {
  /* Only canonical sources that change */
  --background: #171717;
  --foreground: #ffffff;
  --card: #1c1c1c;
  --card-foreground: #f7f7f7;
  --accent: #333333;
  /* derived vars auto-resolve — no repetition needed */
}

when adding a new color token, first check if its value matches an existing canonical. if it does, reference that canonical instead of writing the same hex value again.

shadcn color token convention

use the shadcn/ui CSS custom property convention for design tokens. this makes it trivial to copy-paste shadcn components into the repo without remapping colors. each semantic token is a plain CSS variable in :root (light) with a dark override, then bridged to Tailwind via @theme inline.

core tokens to always define

  • --background / --foreground — page background and default text
  • --card / --card-foreground — card surfaces
  • --popover / --popover-foreground — popovers, dropdowns, tooltips
  • --primary / --primary-foreground — primary buttons, links, accents
  • --secondary / --secondary-foreground — secondary/subtle buttons
  • --muted / --muted-foreground — muted backgrounds and placeholder text
  • --accent / --accent-foreground — hover/active highlights
  • --destructive / --destructive-foreground — delete, error actions
  • --border — default border color
  • --input — form input borders
  • --ring — focus ring color
  • --radius — default border radius

extra tokens to add when needed

expand with the same --name / --name-foreground pattern:

  • --info / --info-foreground — informational badges, alerts
  • --success / --success-foreground — success states
  • --warning / --warning-foreground — warning states
  • --sidebar / --sidebar-foreground — sidebar background and text
  • --sidebar-primary / --sidebar-primary-foreground — sidebar active item
  • --sidebar-accent / --sidebar-accent-foreground — sidebar hover
  • --sidebar-border / --sidebar-ring — sidebar borders and focus rings

always follow the --name / --name-foreground pair convention. never invent a different naming scheme.

@theme inline bridging pattern

in Tailwind v4, CSS custom properties are not automatically available as utility classes. to use them as Tailwind colors, bridge them via @theme inline. this replaces the old theme.extend.colors in tailwind.config.js.

@import 'tailwindcss';

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --color-card: var(--card);
  --color-card-foreground: var(--card-foreground);
  --color-popover: var(--popover);
  --color-popover-foreground: var(--popover-foreground);
  --color-primary: var(--primary);
  --color-primary-foreground: var(--primary-foreground);
  --color-secondary: var(--secondary);
  --color-secondary-foreground: var(--secondary-foreground);
  --color-muted: var(--muted);
  --color-muted-foreground: var(--muted-foreground);
  --color-accent: var(--accent);
  --color-accent-foreground: var(--accent-foreground);
  --color-destructive: var(--destructive);
  --color-destructive-foreground: var(--destructive-foreground);
  --color-border: var(--border);
  --color-input: var(--input);
  --color-ring: var(--ring);
  --radius-sm: calc(var(--radius) - 4px);
  --radius-md: calc(var(--radius) - 2px);
  --radius-lg: var(--radius);
  --radius-xl: calc(var(--radius) + 4px);
}

after this, classes like bg-primary, text-muted-foreground, border-border, rounded-lg just work. add more --color-* entries following the same pattern when you add new semantic tokens.

define the actual values in :root for light mode and inside .dark or @variant dark or @media (prefers-color-scheme: dark) for dark mode — whichever strategy the project uses. check the existing globals.css to see which strategy is in place before adding new tokens.

shadcn CLI

always use bunx shadcn@latest for all shadcn commands. useful commands:

# add components
bunx shadcn@latest add button card dialog

# search registries for components
bunx shadcn@latest search @shadcn -q "sidebar"

# get component docs and example URLs
bunx shadcn@latest docs button dialog select

# preview changes before adding/updating
bunx shadcn@latest add button --dry-run
bunx shadcn@latest add button --diff button.tsx

# project info (framework, aliases, tailwind version, installed components)
bunx shadcn@latest info --json

# apply a preset theme
bunx shadcn@latest apply a2r6bw

always run bunx shadcn@latest docs <component> and fetch the URLs before using a component. this ensures correct API usage rather than guessing from memory.

component selection guide:

NeedUse
Button/actionButton with variant
Form inputsInput, Select, Combobox, Switch, Checkbox, RadioGroup, Textarea, Slider
Toggle 2-5 optionsToggleGroup + ToggleGroupItem
Data displayTable, Card, Badge, Avatar
NavigationSidebar, NavigationMenu, Breadcrumb, Tabs, Pagination
OverlaysDialog (modal), Sheet (side panel), Drawer (bottom), AlertDialog (confirm)
Feedbacksonner (toast), Alert, Progress, Skeleton, Spinner
Command paletteCommand inside Dialog
LayoutSeparator, Resizable, ScrollArea, Accordion, Collapsible
Empty statesEmpty
MenusDropdownMenu, ContextMenu, Menubar
Tooltips/infoTooltip, HoverCard, Popover

to find all available shadcn components and their docs, fetch https://ui.shadcn.com/llms.txt

shadcn/ui project setup

when setting up shadcn/ui in a new project, install these dependencies:

bunx --bun shadcn@latest add init
# or manually:
pnpm add shadcn class-variance-authority clsx tailwind-merge lucide-react tw-animate-css @base-ui/react

components.json with package name aliases (not @)

never use @/ tsconfig path aliases for shadcn. instead, use the package name from package.json as the import prefix. for example, if your package is named my-app, imports look like my-app/src/components/ui/button. this works natively in Node.js, bundlers, and TypeScript without extra bundler config. it also works correctly in monorepos where @ is ambiguous across packages.

example components.json:

{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "css": "src/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "aliases": {
    "components": "my-app/src/components",
    "utils": "my-app/src/lib/utils",
    "ui": "my-app/src/components/ui",
    "lib": "my-app/src/lib",
    "hooks": "my-app/src/hooks"
  }
}

replace my-app with the actual name field from your package.json. leave tailwind.config empty (or omit it) for Tailwind v4. set rsc: true if using React Server Components (e.g. with spiceflow).

package.json exports for the package-name pattern

add a ./src/* export so Node.js and bundlers can resolve my-app/src/... imports:

{
  "name": "my-app",
  "exports": {
    "./package.json": "./package.json",
    "./src/*": "./src/*"
  }
}

tsconfig.json paths for TypeScript resolution

TypeScript's module resolution (especially nodenext or bundler) expects file extensions when resolving through package.json exports, which conflicts with how shadcn generates extensionless imports like my-app/src/lib/utils. adding a paths entry silences these resolution errors by bypassing the exports map and resolving directly to the source files:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "my-app/src/*": ["./src/*"]
    }
  }
}

without this, TypeScript will complain that it cannot resolve my-app/src/lib/utils because the exports map expects my-app/src/lib/utils.ts (with extension). the paths mapping lets extensionless imports work during development while the package.json exports handle runtime resolution in bundlers.

globals.css setup

the full CSS file should import tailwindcss, the animation library, and define the @custom-variant dark directive plus @theme inline bridge:

@import 'tailwindcss';
@import 'tw-animate-css';

@custom-variant dark (&:is(.dark *));

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  /* ... all color tokens (see @theme inline section above) ... */
}

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.145 0 0);
  /* ... all light mode token values ... */
}

.dark {
  --background: oklch(0.145 0 0);
  --foreground: oklch(0.985 0 0);
  /* ... all dark mode token values ... */
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}

cn() helper

create src/lib/utils.ts:

import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

adding components

run bunx --bun shadcn@latest add button (or any component name) from the project root. the CLI reads components.json and places files in the correct directories with the correct import paths. components use @base-ui/react primitives by default when available.

derive colors with opacity, not new hardcoded values

add as few hardcoded colors as possible. derive variations from existing tokens using opacity. this is simpler and auto-adapts in dark mode when the base token changes.

prefer Tailwind's / opacity modifier first — it's the simplest:

<div class="bg-primary/10 border-primary/20 text-primary">...</div>

in CSS, use --alpha() for the same effect:

:root {
  --accent: --alpha(var(--foreground) / 8%);
  --border-subtle: --alpha(var(--foreground) / 5%);
}

when opacity alone isn't enough (e.g. mixing two different colors), use color-mix():

:root {
  --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black));
  --sidebar-foreground: color-mix(in srgb, var(--foreground) 64%, var(--sidebar));
}

both --alpha() and color-mix() produce computed colors that auto-adapt in dark mode. prefer opacity over color-mix() when possible — it's simpler. use color-mix() only when you need to blend two different base colors.

prefer CSS variables over Tailwind's dark: variant

dark mode values should be changed via CSS variables inside @variant dark { } blocks, not by scattering dark: classes on every element. this keeps dark mode logic centralized in one place.

/* GOOD — one place to change */
:root {
  --card: oklch(1 0 0);
  @variant dark {
    --card: oklch(0.21 0.006 285.885);
  }
}

the same pattern works for responsive overrides with @variant lg, @variant sm, etc.:

:root {
  --bleed: 0px;
  @variant lg {
    --bleed: 32px;
  }
}

extract repeated hardcoded values into CSS variables

if you find yourself using the same hardcoded value across many places (e.g. a max-width, a sticky offset, a spacing constant), extract it into a CSS variable in :root. this deduplicates the value and makes it easy to change globally.

:root {
  --content-max-width: 1200px;
  --sticky-top: 64px;
  --prose-gap: 20px;
}

but only do this when the value actually appears in multiple places. a variable used in a single spot adds indirection for no benefit — just inline the value. zero-reference variables should be deleted immediately.

no prefixed variable namespaces

never introduce prefixed variable namespaces like --app-*, --hc-*, --fd-*, --editorial-*. keep everything in the flat shadcn naming style. if a new variable is needed, pick a descriptive name that could plausibly be a shadcn extension (e.g. --text-tertiary, --border-subtle, --divider).

SVG icons — extract into separate components

never inline SVG icons inside a larger component. extract each icon into its own small component that accepts className and spreads ...props. SVG paths are visual noise that buries the actual layout. prefer lucide-react when the icon exists there.

SVGs — always use currentColor

when creating or editing inline SVGs, always use currentColor for fill and stroke instead of hardcoded colors. this way the icon inherits the parent's CSS color property and automatically adapts to dark mode, hover states, and any text color utility.

<!-- GOOD — adapts to parent color -->
<svg fill="currentColor" ...>
<svg stroke="currentColor" fill="none" ...>

<!-- BAD — hardcoded, ignores dark mode -->
<svg fill="#000" ...>
<svg fill="black" ...>

then style with Tailwind's text color utilities: text-foreground, text-muted-foreground, text-primary, etc.

data-URI SVGs cannot use currentColor: SVG used as a CSS background-image data URI (url("data:image/svg+xml,...")) is NOT part of the document tree, so currentColor resolves to black regardless of the parent's color. always use inline <svg> elements (not background-image) for icons that need to adapt to light/dark mode.

dark mode detection in React — hydration-safe with useSyncExternalStore

when a client component needs to know if dark mode is active (e.g. to pass a theme to a third-party library like mermaid, or to swap an image src), never use useState + useEffect + MutationObserver. that pattern causes hydration mismatches because useState(() => document.documentElement.classList.contains('dark')) evaluates during SSR where document doesn't exist.

use useSyncExternalStore with module-level stable callbacks instead:

import { useSyncExternalStore } from 'react'

// Module-level — stable references, never re-allocated
function getIsDark(): boolean {
  return document.documentElement.classList.contains('dark')
}
const getServerIsDark = () => false

function subscribeTheme(cb: () => void) {
  const observer = new MutationObserver(cb)
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
  return () => observer.disconnect()
}

// Inside any component
const isDark = useSyncExternalStore(subscribeTheme, getIsDark, getServerIsDark)

server always returns false (light). React handles the mismatch gracefully during hydration. the MutationObserver fires cb on <html> class changes, triggering a synchronous re-render.

rules:

  • all three callbacks must be stable references (module-level or useCallback). inline arrows cause React to re-subscribe every render.
  • never read document inside the server snapshot. return a safe default.
  • this pattern works for any external state tied to the DOM (scroll position, media queries, <html> attributes, etc.).

prefer cn() for className composition

always use the cn() helper (clsx + tailwind-merge) for composing class names. never use template literals or string concatenation to build className strings. cn() handles falsy values, deduplicates conflicting tailwind classes, and reads much cleaner.

for conditional classes, pass boolean expressions with && inside cn():

// BAD — template literal, no tailwind-merge, hard to scan
<div className={`px-4 py-2 ${isActive ? "bg-primary text-primary-foreground" : "bg-muted"} ${isDisabled ? "opacity-50" : ""}`}>

// BAD — ternary soup
<div className={isActive ? "px-4 py-2 bg-primary text-primary-foreground" : "px-4 py-2 bg-muted"}>

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
43
Forks
2
Last commit
Sep 2026
Hacker News mentions
20

ahel review

  • K1binfo
    installs-packages
  • K1binfo
    installs-packages (in migration-v3-to-v4.md)
  • K1binfo
    installs-packages (in porting-tremor-to-shadcn.md)

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
tailwind-remorses
Source
github.com/remorses/opencode-config