tailwind
Writes and audits Tailwind CSS v4 in react-basics-ui — the CSS-first setup (@import "tailwindcss", @theme), the arbitrary-value-referencing-CSS-variable convention (bg-[color:var(--semantic-*)]), the
SKILL.md.claude/skills/tailwind/SKILL.md
GitHubTailwind CSS (v4) — react-basics-ui Conventions
Write utility classes that resolve to the design-token system and survive the v4 config model. This skill owns how Tailwind is used here: the syntax convention, class composition, and where class strings live.
Tokens themselves (the
--primitive-*→--semantic-*funnel, dark-theme parity, when to add a token) are owned bydesign-systems. Presentational-component structure (prop APIs, variants, stories) is owned bycomponent. This skill is the Tailwind layer underneath both.
Stack Assumptions
Tailwind v4.1 via @tailwindcss/postcss, clsx, tailwind-merge. No @apply, no raw hex in class strings. src/global.css is the single stylesheet: it imports Tailwind itself and is also the published artifact (dist/index.css, exported as react-basics-ui/styles.css).
src/global.css @import 'tailwindcss';
@theme { --breakpoint-* ; --animate-* }
:root { --primitive-* ; --semantic-* ; --component-* }
[data-theme="dark"] { … } + @keyframes
postcss.config.js { '@tailwindcss/postcss': {}, autoprefixer: {} }
src/lib/cn.ts cn() = twMerge(clsx(...))
**/*.styles.ts co-located class-map constants (69 files)
src/components/shared/styles/ reusable class-string fragments (FOCUS_RING, …)
tsup.config.ts builds JS + emits global.css → dist/index.css
The v4 config model — and the live gotcha
Tailwind v4 is CSS-first. There are two ways to configure the theme, and this library deliberately uses only one:
| Mechanism | Lives in | Loaded? | Holds |
|---|---|---|---|
@theme { … } |
src/global.css |
Always (it's CSS) | --breakpoint-*, --animate-* |
tailwind.config.js |
(deleted) | — | — |
⚠️ v4 does not auto-detect
tailwind.config.js. It is loaded only if a CSS file contains@config.
There is no tailwind.config.js in this repo. One existed and was removed: no stylesheet declared @config, so it was never loaded — deleting it produced byte-identical dist/index.css and dist/index.js. If you are tempted to add one back, remember it does nothing until a stylesheet declares @config.
Dead utilities are dangerous precisely because they look fine: body sets color: var(--semantic-text-primary), so a dead text-primary is invisible, while a dead text-error renders in ordinary text colour instead of red. Nothing warns you.
The convention here — configure in CSS, not in the JS config
Anything Tailwind must generate goes in the @theme block of src/global.css, where it is guaranteed to load:
/* src/global.css */
@import 'tailwindcss';
@theme {
--breakpoint-md: 640px;
/* A --animate-* entry is what generates the animate-<name> utility. */
--animate-skeleton-wave: skeleton-wave var(--component-skeleton-duration) ease-in-out infinite;
}
Everything else reaches tokens through arbitrary values (below), which need no config at all.
Do not reintroduce a JS config. The old one was deleted precisely because it could never take effect, and wiring
@configwould switch on ~400 dormant classes at once — a large, untested visual change. If a named utility is genuinely wanted, add it to@themeinstead.
Reaching a token from a class string
Arbitrary value referencing the CSS variable — the convention here
This is how essentially every component in the library is styled. It works without any config, and reaches semantic vars that have no named mapping. Always include the type hint (color: / length:) so v4 parses it correctly.
className="bg-[color:var(--semantic-brand-secondary-default)] h-[length:var(--semantic-height-default)] gap-[length:var(--semantic-space-compact)] rounded-[length:var(--semantic-radius-md)]"
| Bracket form | When |
|---|---|
bg-[color:var(--semantic-…)] |
any color property (bg, text, border, ring, fill) — color: hint required |
h-[length:var(--semantic-…)], gap-[length:var(--semantic-…)], size-[length:var(--semantic-…)] |
dimensions/spacing/radius — length: hint required |
duration-[var(--semantic-…)], opacity-[var(--semantic-…)], ring-[var(--semantic-…)] |
unitless / token-typed values — bare var() |
Tailwind's built-in utilities (flex, truncate, animate-pulse, sm:, hover:) are unaffected and are used freely. Only named utilities that would have come from a JS config are unavailable — there is no such config.
Never write a raw value in brackets to skip the token system:
// BAD — raw value, bypasses the funnel
className="bg-[#fcb711] h-[36px] rounded-[8px]"
// GOOD — token via arbitrary value
className="bg-[color:var(--semantic-brand-secondary-default)] h-[length:var(--semantic-height-default)]"
Class composition — cn()
All conditional/merged class strings go through cn() from @/lib/cn:
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); // clsx flattens conditionals; twMerge dedupes conflicts
}
clsxflattens arrays, objects, and falsy conditionals:cn('px-2', isActive && 'bg-secondary').twMergeresolves Tailwind conflicts so the last wins:cn('p-2', 'p-4')→'p-4'. This is whyclassNameoverrides from props compose correctly — pass the prop'sclassNamelast tocn().
const classes = cn(BASE_CLASSES, SIZE_STYLES[size], VARIANT_STYLES[variant], block && BLOCK_CLASSES, className);
⚠️
tailwind-mergeonly understands default-named utilities and known arbitrary patterns. It will not dedupe two conflictingbg-[color:var(--a)]vsbg-[color:var(--b)]reliably across every property — it does for common ones, but don't rely on merge to resolve two arbitrary color values; pick one upstream.
Where class strings live — the .styles.ts pattern
A component with variants/sizes does not inline long class strings in JSX. It extracts them into a co-located <Component>.styles.ts (this library has 69 of them) and composes with cn().
// Button.styles.ts
import { FOCUS_RING } from '@/components/shared/styles/focus.styles';
import { DISABLED_CLASSES } from '@/components/shared/styles/disabled.styles';
import { TRANSITION_COLORS } from '@/components/shared/styles/transitions.styles';
export const BASE_CLASSES = `inline-flex items-center justify-center rounded-[length:var(--semantic-radius-md)] ${TRANSITION_COLORS} ${FOCUS_RING} ${DISABLED_CLASSES}`;
export const SIZE_STYLES = {
small: 'h-[length:var(--semantic-height-default)]',
default: 'h-[length:var(--semantic-height-medium)]',
large: 'h-[length:var(--semantic-height-large)]',
} as const;
export const VARIANT_STYLES = {
primary: 'bg-[color:var(--semantic-brand-secondary-default)] text-[color:var(--semantic-accent-sand-default)] hover:bg-[color:var(--semantic-brand-secondary-hover)] …',
// …
} as const;
Rules:
- Variant/size maps are
Record<Variant, string>objects withas const— never a chain of ternaries. The component indexesVARIANT_STYLES[variant]. - Compose, don't repeat. Cross-component fragments live in
src/components/shared/styles/and are concatenated intoBASE_CLASSES:
| Fragment | Constant | Covers |
|---|---|---|
focus.styles.ts |
FOCUS_RING, FOCUS_RING_TIGHT |
focus-visible ring via tokens |
disabled.styles.ts |
DISABLED_CLASSES |
disabled cursor + opacity |
transitions.styles.ts |
TRANSITION_COLORS, TRANSITION_ALL, … |
transition + token duration |
statusColors.styles.ts, textColors.styles.ts, spacing.styles.ts, iconSizes.styles.ts, floating.styles.ts |
various | reusable token-bound class sets |
.styles.tsfiles are tested. Each shared fragment has a co-located.test.tsasserting the class string. New shared fragments get one.- Inline classes are fine for one-off layout in a
.tsx('inline-flex shrink-0 items-center'). Extract to.styles.tsonce there's a variant axis or the string is reused.
For how the extracted styles then bind to a component's prop API and variant matrix, see component.
Responsive, state, and dark variants
- Breakpoints are the only
@themecustomization:xs 375 · sm 480 · md 640 · lg 768 · xl 1024 · 2xl 1440. Usemd:,lg:as normal. These differ from Tailwind defaults — don't assumesm= 640. - State variants (
hover:,focus-visible:,active:,disabled:,aria-disabled:,has-[:focus-visible]:) compose with the arbitrary syntax:hover:bg-[color:var(--semantic-surface-hover)]. This is how everyVARIANT_STYLESentry is built. - Dark mode is handled in the token layer, not in utilities. Semantic vars are redefined under
[data-theme="dark"]inglobal.css; a singlebg-[color:var(--semantic-surface-base)]follows the theme automatically. Do not writedark:variants in components — that splits the source of truth. Seedesign-systems.
Audits
Run from the repo root. Each is a grep that surfaces a specific drift.
1. Raw values in class strings (token bypass)
grep -rnE "(bg|text|border|ring|fill|h|w|gap|p|px|py|m|rounded|size|shadow)-\[#" src --include="*.ts" --include="*.tsx"
grep -rnE "(h|w|gap|p|px|py|m|rounded|size)-\[[0-9]+(px|rem|em)\]" src --include="*.ts" --include="*.tsx"
Each hit: replace with a named utility or a var(--semantic-*) arbitrary value. If no token exists, propose one (see design-systems) — don't hardcode.
2. Dead named utilities
# Confirm the config is still unloaded (it should be).
grep -rn "@config" src/*.css || echo "no @config → correct; there is no JS config to load"
grep -rnoE "\b(bg|text|border)-(primary|secondary|success|warning|error|info)(-[a-z]+)?\b" src --include="*.ts" --include="*.tsx" | sort
Every match is a no-op class that emits nothing. Convert each to the arbitrary var() form — do not wire @config to make them work.
3. Missing type hint in arbitrary color/length values
# color properties missing the color: hint
grep -rnE "(bg|text|border|ring|fill)-\[var\(--semantic" src --include="*.ts" --include="*.tsx"
# dimension properties missing the length: hint
grep -rnE "(h|w|gap|size|min-[hw]|max-[hw])-\[var\(--semantic" src --include="*.ts" --include="*.tsx"
Hits parse unreliably in v4. Add color: / length: inside the brackets.
4. dark: variants in components (should be token-driven)
grep -rn "dark:" src/components src/features --include="*.tsx"
Each hit moves the difference into a dark-theme token override in global.css.
5. @apply (banned here)
grep -rn "@apply" src --include="*.css"
Expected: zero. @apply re-hardcodes utilities in CSS and defeats twMerge. Compose with cn() + .styles.ts instead.
6. Long inline class strings that should be a .styles.ts
# className literals with a variant-shaped conditional or 8+ utilities inline
grep -rnE "className=\{?[\"'\`][^\"'\`]{120,}" src/components --include="*.tsx"
Each hit is a candidate for extraction into <Component>.styles.ts.
Decision flowchart
Need to style an element
│
├─ Is there a SEMANTIC token for this intent? (design-systems owns this)
│ └─ NO → add the token first (primitive → semantic → dark override). Don't hardcode.
│
├─ Is there a built-in Tailwind utility or a `@theme` entry for it?
│ └─ YES → use the named utility: text-error bg-secondary rounded-md
│
├─ No named mapping (or reaching a var with no map)?
│ └─ arbitrary value WITH type hint: bg-[color:var(--semantic-…)] h-[length:var(--semantic-…)]
│
├─ Conditional / merged / prop-overridable classes?
│ └─ compose through cn(...) — pass incoming `className` LAST
│
├─ Variant or size axis, or reused string?
│ └─ extract to <Component>.styles.ts as a Record<…, string> `as const`; reuse shared/styles/* fragments
│
└─ Dark-mode difference?
└─ NOT a `dark:` utility — redefine the semantic var under the dark theme in global.css
Common mistakes
| Mistake | Why it's wrong | Fix |
|---|---|---|
bg-[#fcb711], h-[36px] |
Bypasses the token funnel | var(--semantic-*) arbitrary value or named utility |
text-error |
Dead class — emits nothing, renders inherited colour | text-[color:var(--semantic-status-error-default)] |
bg-[var(--semantic-x)] (no color:) |
v4 may mis-parse the type | bg-[color:var(--semantic-x)] |
dark:bg-… in a component |
Splits theme source of truth | Dark override on the token in global.css |
@apply btn-base in CSS |
Re-hardcodes utilities, breaks twMerge |
BASE_CLASSES string + cn() |
| Ternary chain of variant classes in JSX | Unreadable, untestable | VARIANT_STYLES record as const in .styles.ts |
Passing className prop first to cn() |
Consumer override loses to internal classes | Pass incoming className last |
Assuming sm: = 640px |
Here sm = 480px |
Check the @theme breakpoints in global.css |