tailwind v4
SkillDev toolsTailwind 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.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
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:
| Need | Use |
|---|---|
| Button/action | Button with variant |
| Form inputs | Input, Select, Combobox, Switch, Checkbox, RadioGroup, Textarea, Slider |
| Toggle 2-5 options | ToggleGroup + ToggleGroupItem |
| Data display | Table, Card, Badge, Avatar |
| Navigation | Sidebar, NavigationMenu, Breadcrumb, Tabs, Pagination |
| Overlays | Dialog (modal), Sheet (side panel), Drawer (bottom), AlertDialog (confirm) |
| Feedback | sonner (toast), Alert, Progress, Skeleton, Spinner |
| Command palette | Command inside Dialog |
| Layout | Separator, Resizable, ScrollArea, Accordion, Collapsible |
| Empty states | Empty |
| Menus | DropdownMenu, ContextMenu, Menubar |
| Tooltips/info | Tooltip, 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
documentinside 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-packagesK1binfo
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
github.com/remorses/opencode-config
Related picks
Skill · thedaviddias
The pick for JavaScriptmodern-javascript-patterns
Skill · wshobson
The pick for JavaScriptsetup-ts-deep-modules
Skill · mattpocock
The pick for TypeScripttypescript-pro
Skill · jeffallan
The pick for TypeScriptreact-component-performance
Skill · davila7
The pick for Reactreact-doctor
Skill · millionco
The pick for React