A set of opinionated rules for producing clean, consistent, and accessible user interfaces. These guidelines should be followed whenever generating, reviewing, or refining UI code. Default personality: Neutral. Override only when the user explicitly requests a different tone.
- Start by building the core feature UI, not the page shell (navbar, sidebar, footer).
- Layout and navigation patterns should emerge after several features exist.
- Build the initial version using only
text-gray-*andbg-gray-*. - If hierarchy is clear without color, it will only get better with color.
- Add
shadow-*,rounded-*, and decorative classes only after structure is solid.
- Build the simplest useful version of each feature first.
- Handle edge cases and polish in subsequent iterations on the real UI.
- Always prefer Tailwind's default scale values. Avoid arbitrary values (
p-[13px],text-[17px]) unless no scale value works. - If the scale doesn't fit your project, extend it via CSS with the
@themedirective — don't scatter one-off values in markup. - Constraints speed up decisions and create visual consistency.
In Tailwind v4, all customization lives in your CSS file via the @theme directive. There is no JavaScript config file.
/* app.css */
@import 'tailwindcss';
@theme {
--font-sans: 'Inter', sans-serif;
--font-display: 'Cal Sans', sans-serif;
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-500: oklch(0.55 0.18 250);
--color-brand-600: oklch(0.48 0.18 250);
--color-brand-900: oklch(0.25 0.1 250);
--spacing-18: 4.5rem;
--radius-DEFAULT: 0.5rem;
--radius-lg: 0.75rem;
--shadow-soft: 0 1px 3px 0 oklch(0 0 0 / 0.04), 0 1px 2px -1px oklch(0 0 0 / 0.04);
}This generates utility classes like bg-brand-500, font-display, rounded-lg, shadow-soft.
| Namespace | Generates | Example |
|---|---|---|
--color-* |
bg-*, text-*, border-*, ring-* |
--color-brand-500: #4f46e5; |
--font-* |
font-* |
--font-display: 'Cal Sans', sans-serif; |
--spacing-* |
p-*, m-*, gap-*, w-*, h-* |
--spacing-18: 4.5rem; |
--radius-* |
rounded-* |
--radius-xl: 1rem; |
--shadow-* |
shadow-* |
--shadow-soft: 0 1px 3px ...; |
--breakpoint-* |
sm:, md:, lg:, etc. |
--breakpoint-xs: 30rem; |
--container-* |
max-w-* |
--container-narrow: 40rem; |
Use --color-*: initial; inside @theme to strip all defaults, then define only what you need.
For multi-theme setups (dark mode, brand variants), define tokens in @theme referencing CSS custom properties, then override them per context in @layer base:
@theme {
--color-surface: var(--surface);
--color-on-surface: var(--on-surface);
--color-primary: var(--primary);
}
@layer base {
:root {
--surface: oklch(0.99 0 0);
--on-surface: oklch(0.15 0 0);
--primary: oklch(0.55 0.18 250);
}
.dark {
--surface: oklch(0.15 0.01 250);
--on-surface: oklch(0.95 0 0);
--primary: oklch(0.7 0.15 250);
}
}Then use bg-surface, text-on-surface, bg-primary in markup — values swap automatically with the theme.
Every screen should have a clear primary -> secondary -> tertiary structure.
Text color tiers:
| Tier | Role | Tailwind |
|---|---|---|
| Primary | Headlines, key data | text-gray-900 |
| Secondary | Descriptions, metadata | text-gray-500 |
| Tertiary | Captions, fine print | text-gray-400 |
Font weight tiers:
| Tier | Tailwind |
|---|---|
| Normal | font-normal (400) or font-medium (500) |
| Emphasized | font-semibold (600) or font-bold (700) |
- Never use
font-lightorfont-thinfor interface text. To de-emphasize, use a lighter color or smaller size instead.
- If the main element doesn't stand out, don't keep adding emphasis to it — reduce the prominence of everything else.
- Soften inactive items (
text-gray-400) rather than making the active one louder.
- Skip labels when the format is self-evident (emails, phone numbers, prices).
- Merge label and value into a natural phrase:
"12 left in stock"not"In stock: 12". - When labels are required, treat them as supporting content (small, uppercase,
text-gray-500).
- An
<h1>doesn't have to be the biggest thing on screen. - Section titles often function as labels — keep them small (
text-xs font-semibold uppercase tracking-wide text-gray-500).
- Visually heavy elements (icons, bold text) need softer colors to avoid dominating.
- Visually light elements (thin borders) may need more thickness instead of darker color.
Design buttons in three tiers of importance:
- Primary: solid background, high contrast (
bg-indigo-600 text-white) - Secondary: outline or muted (
border border-gray-300 text-gray-700) - Tertiary: link-style (
text-gray-500 hover:underline)
Destructive actions should not automatically be bg-red-600. Prefer secondary styling + a confirmation step where the destructive action becomes the primary button.
- Start with more space than you think (
p-8,gap-8,space-y-6), then tighten. - Compact layouts (
p-2,gap-2) should be a conscious decision (dashboards, data tables).
Use Tailwind's built-in scale. The gaps between values grow as you go up — this is intentional.
| Class | px | Typical use |
|---|---|---|
1 |
4px | Tight inline gaps |
1.5 |
6px | Label-to-input spacing |
2 |
8px | Compact padding |
3 |
12px | Button vertical padding |
4 |
16px | Standard padding/gaps |
6 |
24px | Within-section spacing |
8 |
32px | Between groups |
12 |
48px | Major section gaps |
16 |
64px | Page section separation |
20-24 |
80-96px | Hero-level spacing |
If two spacing values look nearly identical on screen, you're choosing between options that are too close. Jump a step.
| Class | px | Use for |
|---|---|---|
max-w-xs |
320px | Small cards, modals |
max-w-sm |
384px | Forms, login cards |
max-w-lg |
512px | Medium content |
max-w-2xl |
672px | Articles, readable text |
max-w-4xl |
896px | Wide content areas |
max-w-7xl |
1280px | Page containers |
Custom container widths in v4: --container-narrow: 40rem; in @theme generates max-w-narrow.
If content only needs 400px, don't stretch it to fill 1200px.
Sidebars, avatars, icons, and form fields usually need fixed sizes (w-64 shrink-0). Let the main content area flex (flex-1 min-w-0).
Don't assume proportional relationships hold across screen sizes. Padding should grow disproportionately at larger sizes.
The space within a group must be noticeably smaller than the space between groups (e.g. space-y-1.5 within, space-y-6 between).
Stick to Tailwind's scale. No arbitrary pixel values.
| Class | Size | Typical use |
|---|---|---|
text-xs |
12px | Badges, fine print |
text-sm |
14px | Labels, metadata |
text-base |
16px | Body text |
text-lg |
18px | Lead text, card titles |
text-xl |
20px | Section titles |
text-2xl |
24px | Page headings |
text-3xl |
30px | Hero subtitles |
text-4xl |
36px | Hero titles |
text-5xl+ |
48px+ | Display text |
Default to font-sans (system stack). Custom fonts via @theme: --font-sans, --font-display. Choose typefaces with 5+ weights and good UI readability.
Constrain paragraphs with max-w-prose (~65ch) even inside wider containers.
Use items-baseline — not items-center — when font sizes differ on the same line.
| Text type | Class | Ratio |
|---|---|---|
Body (text-sm-text-base) |
leading-relaxed |
~1.625 |
Large (text-xl+) |
leading-snug |
~1.375 |
Headlines (text-3xl+) |
leading-tight or leading-none |
~1-1.25 |
Smaller text needs more line-height. Larger text needs less.
- Navigation links: weight or color only (
font-medium text-gray-900), no underline - Ancillary links: subtle, visible on hover (
text-gray-500 hover:underline) - Inline prose links: bright color (reserve for this context only)
- Default:
text-left. - Center only for short blocks (2-3 lines max).
- Numbers in tables:
text-right tabular-nums. - Justified: only with
hyphens-auto.
| Class | When to use |
|---|---|
tracking-tight |
Headlines (text-3xl+) |
tracking-normal |
Body (default, leave alone) |
tracking-wide |
Uppercase text, small labels |
| Shade | Purpose |
|---|---|
50 |
Tinted backgrounds (alerts, highlights) |
100 |
Hover backgrounds, subtle fills |
200 |
Borders, dividers |
300 |
Disabled borders, muted icons |
400 |
Placeholder text, secondary icons |
500 |
Default accent (links, buttons) |
600 |
Hover/active accent |
700 |
Dark accent, bold emphasis |
800 |
Text on tinted backgrounds |
900 |
Primary text |
950 |
Near-black text |
Define brand palette in @theme using oklch (perceptual uniformity). Full shade ramp from --color-brand-50 to --color-brand-950. This generates bg-brand-500, text-brand-900, border-brand-200, etc.
| Role | Example classes |
|---|---|
| Greys | text-gray-900, bg-gray-50, border-gray-200 |
| Primary | bg-brand-600, text-brand-600, bg-brand-50 |
| Success | bg-green-50 text-green-800 |
| Warning | bg-amber-50 text-amber-800 |
| Danger | bg-red-50 text-red-800 |
| Info | bg-blue-50 text-blue-800 |
Use same-hue lighter shade (e.g. text-indigo-200 on bg-indigo-600). Never use grey text or transparent white on colored backgrounds.
Pick one grey family and use it everywhere:
| Family | Feel |
|---|---|
slate |
Cool, blue-tinted — tech, corporate |
gray |
Balanced neutral |
zinc |
Cool but understated |
neutral |
True neutral, no tint |
stone |
Warm, yellow-tinted — friendly, editorial |
Default: gray. Switch only when the user explicitly requests a warmer or cooler feel.
Pair color with icons, text, or contrast so colorblind users can interpret the UI.
This section is mandatory. Every component must follow these rules.
| Text size | Minimum ratio | Safe Tailwind colors on white |
|---|---|---|
Normal (up to text-base) |
4.5:1 | text-gray-600 and darker |
Large (text-lg+) |
3:1 | text-gray-500 and darker |
| Decorative / placeholder only | - | text-gray-400 |
Prefer light backgrounds over dark for alerts/badges (bg-green-50 text-green-800 over bg-green-700 text-white).
- Every interactive element must have a visible
focus-visiblering. - Use
focus-visible(notfocus) to avoid showing outlines on click. - Never use
outline-nonewithout afocus-visible:ring-*replacement.
| Element | Required attributes |
|---|---|
| Icon-only buttons | aria-label="..." |
| Decorative icons/images | aria-hidden="true" |
| Loading spinners | role="status" + <span class="sr-only">Loading...</span> |
| Alerts / toasts | role="alert" or role="status" |
| Navigation landmarks | <nav aria-label="Main navigation"> |
| Main content | <main id="main"> |
| Form errors | aria-describedby="..." pointing to the error message |
| Required fields | aria-required="true" (or native required) |
| Toggle states | aria-expanded="true/false" |
| Current page in nav | aria-current="page" |
| Tabs | role="tablist", role="tab", role="tabpanel" + aria-selected |
- Every
<input>must have a<label>with a matchingfor/idpair. No exceptions. - Error messages must be linked via
aria-describedby. - Invalid fields must have
aria-invalid="true". - Required fields must have
aria-required="true"or nativerequired. - Placeholder is NOT a substitute for a label.
- DOM order = tab order. Don't use
tabindex> 0. tabindex="0"for custom focusable widgets.tabindex="-1"for programmatic focus (e.g., modal title).- Logical reading order: header -> main content -> sidebar -> footer.
Use motion-safe: and motion-reduce: variants on all animations and transitions.
- Informative images: always provide meaningful
alttext. - Decorative images:
alt=""oraria-hidden="true". - User avatars:
object-cover+ fixed size +ring-1 ring-black/5.
| Class | Use | Perceived distance |
|---|---|---|
shadow-sm |
Buttons, inputs | Slightly raised |
shadow |
Cards, panels | Resting surface |
shadow-md |
Dropdowns, popovers | Floating |
shadow-lg |
Sticky elements | Elevated |
shadow-xl / shadow-2xl |
Modals, dialogs | Foreground |
Choose shadows by purpose, not decoration. Custom shadows via @theme: --shadow-soft, --shadow-card.
- Interactive shadows: hover lift (
hover:shadow-lg), press down (active:shadow-none). Always addmotion-reduce:transition-none. - Inset depth:
shadow-innerfor recessed content,ring-1 ring-inset ring-white/10for subtle button depth. - Depth without shadows: lighter = closer (
bg-whiteonbg-gray-100), darker = further. - Layered depth: overlapping elements with negative margin (
-mt-12) +shadow-lg.
- Text over images: use a dark overlay (
bg-black/40) +drop-shadow-lgon text. - Small icons: don't upscale — wrap in a colored container (
w-12 h-12 rounded-lg bg-indigo-100aroundw-6 h-6). - User-uploaded content:
object-cover+ fixed size +ring-1 ring-black/5(notborder).
Use Alpine.js for UI state (toggles, dropdowns, modals, tabs, accordions). Don't use it for simple hover states or CSS-only animations.
- Include Alpine via CDN with
defer, or bundle it. - Focus plugin required for modals (
x-trap.noscroll.inert). - Collapse plugin optional for smooth accordions.
- Always add
[x-cloak] { display: none !important; }in CSS.
| Rule | Details |
|---|---|
Never use @ shorthand |
Use x-on:click not @click, x-bind:class not :class. The static generator strips @-prefixed attributes. |
Always pair x-show with x-cloak |
Prevents flash of content before Alpine initializes |
| Always add ARIA attributes | aria-expanded, aria-controls, aria-haspopup, role — never skip these |
| Escape key closes overlays | x-on:keydown.escape.window="open = false" on dropdowns and modals |
| Click outside closes | x-on:click.outside="open = false" on dropdown menus |
| Focus trap for modals | Use x-trap.noscroll.inert — never build a modal without it |
| Teleport modals | x-teleport="body" to avoid z-index issues |
| Transitions respect motion | motion-reduce:transition-none on animated elements |
| Modal accessibility | role="dialog" + aria-modal="true" + aria-labelledby are mandatory |
| Tab pattern | Arrow keys move between tabs, only active tab has tabindex="0", rest get -1 |
- Upgrade default elements: icon bullets instead of dots, branded checkboxes, decorative blockquote marks.
- Accent borders: card top gradient, alert left accent (
border-l-4), page top accent. - Background variety: alternate
bg-whiteandbg-gray-50sections. Gradient hues within ~30deg of each other. - Empty states: illustration + message + CTA button. Empty states are the first thing a user sees — prioritize them. Hide unused tabs/filters until content exists.
- Reduce borders: before
border/divide-y, try: (1) shadow, (2) background contrast, (3) spacing. - Rethink components: grid dropdowns with icons, selectable card radio buttons, hierarchical table cells.
Navigations are animated natively (@view-transition{navigation:auto}, inlined by base.html.twig). Core names #navbar -> pw-navbar and [data-variant-zone] -> pw-content; everything else cross-fades with root.
- A
view-transition-nameused twice on a page aborts the whole page's transition — silently. Only name elements guaranteed to appear once, and prefer#idselectors over element/class ones. - Never set
view-transition-nameinside a{% for %}loop (content_part,cardList,pages_list) unless you generate a unique name per item. - Tailwind cannot reach
::view-transition-old()/::view-transition-new()— they sit outside the document tree. Write plain CSS. - Keep names and transforms inside
@media (prefers-reduced-motion: no-preference); the browser still cross-fades for everyone else.
Full detail: packages/docs/content/view-transitions.md.
Unless the user explicitly requests a different personality, always use the Neutral profile.
| Token | Neutral (default) | Playful | Formal |
|---|---|---|---|
| Font | Inter / system stack |
Nunito, Poppins |
DM Sans, Serif |
| Grey | gray |
stone |
slate |
| Primary | indigo |
pink, teal, violet |
gray, amber |
| Radius | rounded-md / rounded-lg |
rounded-full / rounded-2xl |
rounded-none / rounded-sm |
| Shadows | shadow-sm |
shadow-lg |
shadow-none |
| Tone | Clear, straightforward | Casual, friendly | Professional, formal |
/* Neutral (default) */
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--radius-DEFAULT: 0.375rem;
--radius-lg: 0.5rem;
}
/* Playful — only if user asks for it */
@theme {
--font-sans: 'Nunito', ui-sans-serif, system-ui, sans-serif;
--radius-DEFAULT: 1rem;
--radius-lg: 1.5rem;
}
/* Formal — only if user asks for it */
@theme {
--font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif;
--radius-DEFAULT: 0;
--radius-lg: 0.125rem;
}- "fun / playful / friendly / casual" or children's app -> Playful
- "professional / corporate / formal / serious" or law firm / finance -> Formal
- SaaS dashboard, generic app -> stay Neutral
- If ambiguous, ask the user. Never guess.
- One
rounded-*value for cards, buttons, and inputs. - One grey family across the whole project.
- One tone of language throughout all microcopy.
- Define all design tokens in
@themeso the system enforces consistency.
Before shipping any screen:
Visual hierarchy
- 3-tier text hierarchy (
text-gray-900->500->400) - Max 2 font weights in use
- Consistent
rounded-*across all components - Single grey family used throughout
- Personality profile is Neutral unless explicitly requested otherwise
Spacing & layout
- All spacing from Tailwind scale (no arbitrary values)
- All font sizes from Tailwind scale
- All colors from the palette or defined in
@theme - Custom tokens defined in
@theme, not as arbitrary values in markup - Prose width <=
max-w-prose/max-w-2xl - Intra-group spacing < inter-group spacing
- Shadows match element purpose
- Borders justified — tried shadow, bg, or spacing first
Typography
- Mixed font sizes use
items-baseline - Headlines:
tracking-tight/ Uppercase:tracking-wide
Accessibility (mandatory)
- Text contrast meets WCAG AA
- Every interactive element has a visible
focus-visiblering - Every
<input>has a<label>with matchingfor/id - Icon-only buttons have
aria-labelorsr-onlytext - Decorative icons/images have
aria-hidden="true" - Informative images have meaningful
alttext - Error messages linked via
aria-describedby - Color never used alone — paired with icons/text
- Empty states designed with illustration + CTA
-
motion-reduce:variants applied to all animations/transitions - Form validation uses
aria-invalidandaria-describedby
Alpine.js (when used)
-
x-cloakadded to allx-showelements - Dropdowns/modals have
x-on:keydown.escapehandler - Modals use
x-trap.noscroll.inertfrom Focus plugin - Modals have
role="dialog"+aria-modal="true"+aria-labelledby - Toggle buttons have
x-bind:aria-expandedbinding - Tabs use proper
role="tablist"/role="tab"/role="tabpanel"+ arrow key navigation -
x-teleport="body"used for modals to avoid z-index issues
Images
- User images:
object-cover+ fixed size +ring-1 ring-black/5 - No upscaled small icons — wrapped in colored containers instead