tailwind
Most React apps style with utility classes, and most spend their first afternoon wiring Tailwind to the design system: which blue is "primary", what dark mode keys off, how a component lets callers override its padding. kitr add tailwind does that wiring. bg-background, text-primary and rounded-md use the app's colours and radii, dark mode follows the theme toggle, and cn() merges class names so the last one wins.
It is Tailwind CSS v4 and its Vite plugin, plus a preset stylesheet that maps kitr's design tokens onto Tailwind's theme, and cn() built on clsx and tailwind-merge. For plain CSS or CSS Modules, skip this package and use the helpers in theme.
The examples style the orders list in Acme's dashboard at app.acme.com: a status badge, an order card and a dark mode.
Install
kitr add tailwindFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
- Adds react first if the project does not have it, because tailwind requires it.
- Installs
@kitr/tailwind,tailwindcss,@tailwindcss/vite,clsx,tailwind-mergeandclass-variance-authority. - Adds
@kitr/tailwindtopackagesinkitrrc.ts. Thekitr()Vite plugin sees it there and loads@tailwindcss/vite. - Writes
config/tailwind.ts(see Configure). - Writes
app/styles/app.css, the stylesheet Tailwind starts from. Whenapp/styles/tokens.cssexists (the theme package writes it), it is imported last, so your token values win. - In
app/document.tsx(orapp/routes/__root.tsx), swaps the linked@kitr/theme/styles.cssforapp/styles/app.css, so the theme stylesheet loads once, throughapp.css. A document with neither gets the new sheet added. - Adds a
## kitr: tailwindsection toAGENTS.md.
It registers no provider, adds no env variables and asks no questions. Files that already exist are left alone. The app and fullstack kits, and presets built on them, include tailwind.
kitr remove tailwind takes @kitr/tailwind out of kitrrc.ts, uninstalls @kitr/tailwind, tailwindcss, @tailwindcss/vite and tailwind-merge, deletes config/tailwind.ts and the AGENTS.md section. It leaves app/styles/app.css, the import in app/document.tsx, clsx and class-variance-authority, because your components may still use them.
Configure
kitr add writes this file:
// config/tailwind.ts
import { defineConfig } from '@kitr/tailwind'
export default defineConfig({
entry: 'app/styles/app.css',
preset: true,
})
| Option | Default | Description |
|---|---|---|
entry |
'app/styles/app.css' |
The stylesheet Tailwind starts from, relative to the project root. |
preset |
true |
Whether the app uses kitr's preset (@kitr/tailwind/preset.css). |
Tailwind v4 is configured in CSS; these two options describe that file so kitr doctor can check it. To change the entry, link another file from app/document.tsx and set entry. To drop the preset, see Use plain Tailwind colors.
The stylesheet kitr add writes is the real configuration:
/* app/styles/app.css */
@import 'tailwindcss';
@import '@kitr/tailwind/preset.css';
@import '@kitr/theme/styles.css';
@source '../';
With app/styles/tokens.css present, @import './tokens.css'; follows the theme import.
tailwindcssloads Tailwind's base styles and utilities.preset.cssmaps kitr's tokens onto Tailwind's theme, makesdark:followdata-theme="dark"and sets the page's base colours and font.@kitr/theme/styles.cssdefines the token values as--kitr-*CSS variables, for light and dark.@source '../'tells Tailwind to scanapp/for class names.
The Vite side is one option on the kitr() plugin in vite.config.ts:
| Option | Default | Description |
|---|---|---|
tailwind |
true when @kitr/tailwind is in kitrrc.ts |
Load @tailwindcss/vite. Set false to leave it out, as the generated vitest.config.ts does. |
There are no env variables and no tailwind.config.js: Tailwind v4 reads its theme from CSS.
Style your first component
Class names are plain strings, so a styled component is a component with className. Here is the badge for an order's status:
// app/components/orders/order-status-badge.tsx
export function OrderStatusBadge({ status }: { status: 'paid' | 'pending' | 'refunded' }) {
return (
<span className="inline-flex items-center rounded-md border border-border bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{status}
</span>
)
}
Use it anywhere:
// app/pages/orders/index.tsx
import { OrderStatusBadge } from '#app/components/orders/order-status-badge'
export default function OrdersPage() {
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="text-2xl font-semibold text-foreground">Orders</h1>
<p className="mt-2 text-muted-foreground">
Order ACME-1042 from Grace <OrderStatusBadge status="paid" />
</p>
</main>
)
}
Run npm run dev and open /orders. The heading uses the theme's foreground colour and the badge the muted surface, border and radius. Switch to dark and both follow without a dark: class.
Use the theme's colors
The preset maps every kitr token to a Tailwind theme variable. Each colour works with every colour utility (bg-, text-, border-, ring-...) and with opacity modifiers like bg-primary/90.
| Class | Token | Use it for |
|---|---|---|
bg-background, text-foreground |
--kitr-color-background, --kitr-color-foreground |
The page and its text. |
bg-muted, text-muted-foreground |
--kitr-color-muted, --kitr-color-mutedForeground |
Quiet surfaces and secondary text. |
bg-primary, text-primary-foreground |
--kitr-color-primary, --kitr-color-primaryForeground |
Main buttons and links. |
bg-destructive, text-destructive-foreground |
--kitr-color-destructive, --kitr-color-destructiveForeground |
Delete buttons and errors. |
border-border |
--kitr-color-border |
Borders and dividers. |
ring-ring |
--kitr-color-ring |
Focus rings. |
rounded-sm, rounded-md, rounded-lg |
--kitr-radius-sm, -md, -lg |
Corner radii (0.25, 0.5 and 0.75 rem by default). |
font-sans, font-mono |
--kitr-font-sans, --kitr-font-mono |
The system font stacks. |
shadow-sm, shadow-md |
--kitr-shadow-sm, --kitr-shadow-md |
Soft elevation. |
The preset also defaults every border to border-border and gives <body> the background, text colour and sans font, so a plain border class draws a themed line.
// app/components/orders/order-card.tsx
export function OrderCard({ number, customer, total }: { number: string; customer: string; total: string }) {
return (
<article className="rounded-lg border bg-background p-4 shadow-sm">
<h2 className="font-mono text-sm text-muted-foreground">{number}</h2>
<p className="mt-1 text-lg text-foreground">{customer}</p>
<p className="mt-3 font-semibold text-primary">{total}</p>
<button type="button" className="mt-4 rounded-md bg-destructive px-3 py-1.5 text-sm text-destructive-foreground hover:bg-destructive/90">
Refund
</button>
</article>
)
}
Tailwind's own palette (bg-sky-500) is still there for one-off colours such as chart series; use theme classes for anything that should change with the theme.
Change a color
Token values live in CSS variables, so set one after the imports. Acme's primary colour is orange:
/* app/styles/app.css */
@import 'tailwindcss';
@import '@kitr/tailwind/preset.css';
@import '@kitr/theme/styles.css';
@source '../';
:root {
--kitr-color-primary: oklch(68% 0.19 45);
--kitr-color-ring: oklch(68% 0.19 45);
--kitr-radius-md: 0.375rem;
}
[data-theme="dark"] {
--kitr-color-primary: oklch(75% 0.16 45);
--kitr-color-ring: oklch(75% 0.16 45);
}
Every bg-primary, text-primary and ring-ring in the app, and every component from kitr ui add, turns orange. Set the dark values under [data-theme="dark"], or the light ones are used in both themes.
Add your own colors and sizes
For a token kitr does not have, add it to Tailwind's theme with an @theme block. Each --color-* variable becomes a colour utility, --text-* a font size, and so on:
/* app/styles/app.css */
@import 'tailwindcss';
@import '@kitr/tailwind/preset.css';
@import '@kitr/theme/styles.css';
@source '../';
@theme {
--color-success: oklch(62% 0.17 150);
--color-success-foreground: oklch(99% 0 0);
--text-display: 2.5rem;
}
// app/components/orders/paid-banner.tsx
export function PaidBanner() {
return (
<div className="rounded-md bg-success p-3 text-success-foreground">
<p className="text-display">Paid</p>
</div>
)
}
A colour that should differ in dark mode goes through a variable of your own, the way the preset does it:
/* app/styles/app.css */
:root { --acme-success: oklch(62% 0.17 150); }
[data-theme="dark"] { --acme-success: oklch(72% 0.15 150); }
@theme inline {
--color-success: var(--acme-success);
}
@theme inline makes the utility use the variable itself, so it changes with data-theme. The Tailwind theme docs list every namespace.
Support dark mode
The preset redefines the dark: variant to match data-theme="dark" on any ancestor, which useTheme() from theme sets on <html>:
/* from @kitr/tailwind/preset.css */
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
Theme classes switch by themselves. Use dark: only for colours outside the theme:
// app/components/orders/order-chart-legend.tsx
export function OrderChartLegend() {
return (
<ul className="flex gap-4 text-sm">
<li className="text-sky-700 dark:text-sky-300">Online</li>
<li className="text-amber-700 dark:text-amber-300">In store</li>
</ul>
)
}
A toggle button calls useTheme():
// app/components/theme-button.tsx
import { useTheme } from '@kitr/theme'
export function ThemeButton() {
const { resolved, toggle } = useTheme()
return (
<button type="button" onClick={toggle} className="rounded-md border px-3 py-1.5 text-sm hover:bg-muted">
{resolved === 'dark' ? 'Light mode' : 'Dark mode'}
</button>
)
}
Tailwind's default dark: follows the operating system. With the preset it follows the attribute, so the user's choice in the app wins; the theme's 'system' setting still follows the OS by setting the attribute for you.
Merge classes with cn()
cn() takes any mix of strings, objects, arrays and falsy values, joins them with clsx, then removes Tailwind classes that a later class overrides:
import { cn } from '@kitr/tailwind'
cn('rounded-md border p-4', 'p-8')
// => 'rounded-md border p-8'
cn('px-3 py-1', { 'opacity-50': true, hidden: false }, ['text-sm', undefined])
// => 'px-3 py-1 opacity-50 text-sm'
cn('bg-primary hover:bg-primary/90', 'bg-destructive')
// => 'hover:bg-primary/90 bg-destructive'
cn('text-sm text-muted-foreground', 'text-destructive')
// => 'text-sm text-destructive'
Without merging, 'p-4 p-8' leaves both classes on the element and stylesheet order, not your order, decides. tailwind-merge knows which conflict: text-sm (size) and text-destructive (colour) both stay; px-2 then p-4 leaves only p-4.
Accept a className prop
Components take className and put it last in cn(), so callers can adjust them without a new prop per change:
// app/components/orders/order-card.tsx
import { cn } from '@kitr/tailwind'
import type { ComponentProps } from 'react'
export function OrderCard({ className, ...props }: ComponentProps<'article'>) {
return <article className={cn('rounded-lg border bg-background p-4 shadow-sm', className)} {...props} />
}
<OrderCard className="p-8 shadow-md" />
// renders class="rounded-lg border bg-background p-8 shadow-md"
Build variants with cva
For a component with variants (a status, a size), class-variance-authority maps each to classes. kitr add tailwind installs it and the kitr ui add components use it:
// app/components/orders/order-status-badge.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@kitr/tailwind'
import type { ReactNode } from 'react'
export const orderStatusBadge = cva('inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium', {
variants: {
status: {
paid: 'bg-primary text-primary-foreground',
pending: 'bg-muted text-muted-foreground',
refunded: 'bg-destructive text-destructive-foreground',
},
size: {
sm: 'text-xs',
md: 'px-3 py-1 text-sm',
},
},
defaultVariants: { status: 'pending', size: 'sm' },
})
type Props = VariantProps<typeof orderStatusBadge> & { className?: string; children: ReactNode }
export function OrderStatusBadge({ status, size, className, children }: Props) {
return <span className={cn(orderStatusBadge({ status, size }), className)}>{children}</span>
}
<OrderStatusBadge status="refunded" size="md">Refunded</OrderStatusBadge>
// class="inline-flex items-center rounded-md font-medium bg-destructive text-destructive-foreground px-3 py-1 text-sm"
cn() around the cva call lets size: 'md' replace the base px-2 py-0.5 text-xs instead of adding to it. cva is also re-exported from @kitr/theme.
Teach cn() about your own tokens
tailwind-merge only knows Tailwind's built-in scales, so it takes a custom size such as text-display for a colour and cn('text-display', 'text-primary') drops it. Build your own cn with extendTailwindMerge:
// app/lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { extendTailwindMerge } from 'tailwind-merge'
const merge = extendTailwindMerge({
extend: { theme: { text: ['display'] } },
})
export function cn(...inputs: ClassValue[]) {
return merge(clsx(inputs))
}
cn('text-display', 'text-primary')
// => 'text-display text-primary'
Custom colours need nothing: cn('bg-success', 'bg-primary') already returns 'bg-primary'. clsx and twMerge are also exported from @kitr/tailwind.
Tell Tailwind where your classes are
Tailwind generates only the classes it finds in your source files. @source '../' points it at app/; classes written elsewhere need their own line:
/* app/styles/app.css */
@import 'tailwindcss';
@import '@kitr/tailwind/preset.css';
@import '@kitr/theme/styles.css';
@source '../';
@source '../../server/mail';
@source '../../node_modules/@acme/ui/dist';
Tailwind finds classes by reading the files as text, so a class has to appear whole:
// Don't: Tailwind never sees "bg-primary" or "bg-destructive"
<span className={`bg-${order.paid ? 'primary' : 'destructive'}`} />
// Do: both full class names are in the source
<span className={order.paid ? 'bg-primary' : 'bg-destructive'} />
A class that appears in the HTML but has no style is almost always built from pieces or in a folder no @source covers.
Style kitr's own components
Headless kitr components mark their parts with data-kitr attributes, so Tailwind's arbitrary variants can reach them. The field wrapper from forms is one:
// app/components/orders/refund-reason-field.tsx
import { FieldInput, FormField } from '@kitr/forms'
export function RefundReasonField() {
return (
<FormField
name="reason"
label="Reason for the refund"
className="grid gap-1.5 [&_[data-kitr=label]]:text-sm [&_[data-kitr=label]]:font-medium [&_[data-kitr=field-error]]:text-sm [&_[data-kitr=field-error]]:text-destructive"
>
<FieldInput className="rounded-md border bg-background px-3 py-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" />
</FormField>
)
}
For a finished look, kitr ui add form copies a styled version into app/components/ui/form.tsx. Every component kitr ui add copies is plain Tailwind on theme classes and cn(), and the code is yours to change.
Use plain Tailwind colors
To use Tailwind without kitr's tokens, remove the preset import, and the theme stylesheet if nothing else needs it:
/* app/styles/app.css */
@import 'tailwindcss';
@source '../';
Then set preset: false in config/tailwind.ts. Without the preset, bg-primary and the other theme classes stop existing, dark: follows prefers-color-scheme again, and kitr ui add components lose their colours.
Wire Tailwind into a custom Vite config
The kitr() plugin loads @tailwindcss/vite for you. If you build a Vite config without kitr(), or want to control plugin order, turn it off there and add kitrTailwind() from @kitr/tailwind/vite:
// vite.config.ts
import { defineConfig } from 'vite'
import { kitr } from '@kitr/vite'
import { kitrTailwind } from '@kitr/tailwind/vite'
export default defineConfig({
plugins: [kitrTailwind(), kitr({ plugins: { tailwind: false } })],
})
Fix styles that do not apply
Check these causes in order:
| Symptom | Cause | Fix |
|---|---|---|
| No Tailwind styles at all | app/styles/app.css is not imported, or @kitr/tailwind is missing from packages in kitrrc.ts so the Vite plugin is not loaded. |
Import #app/styles/app.css in app/document.tsx; run kitr doctor. |
bg-primary and other theme classes do nothing |
The preset import is missing from app/styles/app.css. |
Add @import '@kitr/tailwind/preset.css'; after @import 'tailwindcss';. |
| Theme classes resolve but have no colour | @kitr/theme/styles.css is not imported, so the --kitr-* variables are undefined. |
Add @import '@kitr/theme/styles.css';. |
dark: classes never apply |
Nothing sets data-theme="dark" on <html>. |
Mount the theme provider and switch with useTheme(). |
| One class works in one file but not another | The file is outside every @source folder, or the class is built from pieces. |
Add an @source line, or write the whole class name. |
A caller's className does not win |
The component joins classes with a template string. | Use cn(base, className). |
| A custom class disappears when merged | tailwind-merge treats it as a conflicting class. | Extend it (Teach cn() about your own tokens). |
kitr doctor warns when vite.config.ts does not use the kitr() plugin. In devtools, a class on the element with no matching rule was not generated; one with a rule crossed out was overridden.
Testing
Tests do not build CSS: the generated vitest.config.ts runs kitr({ plugins: { tailwind: false } }). A test checks which classes a component renders, so test cn() merges and variant classes directly:
// app/components/orders/order-status-badge.test.tsx
import { render, screen } from '@testing-library/react'
import { cn } from '@kitr/tailwind'
import { describe, expect, it } from 'vitest'
import { OrderStatusBadge, orderStatusBadge } from './order-status-badge'
describe('OrderStatusBadge', () => {
it('uses the theme colours for each status', () => {
expect(orderStatusBadge({ status: 'paid' })).toContain('bg-primary text-primary-foreground')
expect(orderStatusBadge()).toContain('bg-muted text-muted-foreground')
})
it('lets a size replace the base padding', () => {
render(
<OrderStatusBadge status="refunded" size="md">
Refunded
</OrderStatusBadge>,
)
const badge = screen.getByText('Refunded')
expect(badge.className).toContain('px-3 py-1 text-sm')
expect(badge.className).not.toContain('px-2')
})
it('lets callers override classes', () => {
render(
<OrderStatusBadge status="paid" className="rounded-lg">
Paid
</OrderStatusBadge>,
)
expect(screen.getByText('Paid').className.split(' ')).toContain('rounded-lg')
expect(screen.getByText('Paid').className.split(' ')).not.toContain('rounded-md')
})
it('merges with cn the way the component does', () => {
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4')
expect(cn('text-sm', { 'text-lg': true }, false, undefined)).toBe('text-lg')
})
})
To check how a page looks, light and dark, use a browser test with Playwright against npm run dev.