
Web Rules
- 85 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
web-rules is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
Key points
- web-rules
- AI & Agent Building
- AI-coding skill
Web Rules by the numbers
- 85 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #5,069 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill web-rulesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 85 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I helps with ai & agent building tasks during ai-assisted development?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with web-rules.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during ai-assisted development, or when web-rules is a claude code skill for ai & agent building. it helps solo builders move faster with ai-assisted coding.
What you get
Structured output aligned to web-rules: web-rules; AI & Agent Building; AI-coding skill.
Files
React 19 + Next.js 16 + Tailwind CSS Best Practices
Comprehensive strict-rules reference for web apps built on React 19, the Next.js 16 App Router, and Tailwind CSS 4. Contains 34 rules across 6 categories. Each rule is stated as an Always/Never directive with a quantified impact, an incorrect example, and a correct example.
Stack Contract
All guidance assumes:
- React 19 with Server Components by default; Client Components only when interactivity is required (
'use client'at the top) - Next.js 16 App Router with
app/directory,layout.tsx,page.tsx,loading.tsx,error.tsx,not-found.tsx, parallel routes, intercepting routes - Server Actions for mutations (
'use server') — neveruseEffectfor data fetching - Tailwind CSS 4 with the
@themedirective,dark:variant, container queries, and the standard 4pt spacing scale - lucide-react as the canonical icon system
- No CSS-in-JS (no styled-components, no emotion) — Tailwind utility classes only, with
cn()fromclsx+tailwind-mergefor conditional classes - shadcn/ui primitives (Radix-based) preferred for dialogs, popovers, dropdowns, tooltips, toasts
When to Apply
Reference these rules when:
- Building any user-facing route, layout, or component
- Reviewing PRs for design / UX / accessibility regressions
- Choosing between modality types (dialog vs popover vs full-page)
- Implementing forms with Server Actions and
useFormState/useOptimistic - Configuring loading and error boundaries
- Designing onboarding, permissions, or settings flows
- Ensuring dark mode, focus management, and keyboard navigation work end-to-end
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Navigation | CRITICAL | nav- |
| 2 | Interaction Design | CRITICAL | inter- |
| 3 | Accessibility | CRITICAL | acc- |
| 4 | User Feedback | HIGH | feed- |
| 5 | UX Patterns | HIGH | ux- |
| 6 | Visual Design | HIGH | vis- |
Quick Reference
1. Navigation (CRITICAL)
- `nav-primary` - Use top nav (3-7 sections) or sidebar; never hamburger-only on desktop
- `nav-app-router` - Use App Router layouts, parallel routes, and
<Link>for all internal navigation - `nav-page-actions` - Place primary actions in the page header; never bury them in scroll
2. Interaction Design (CRITICAL)
- `inter-touch-targets` - 44×44 px minimum touch target (WCAG 2.5.5)
- `inter-pointer-patterns` - Use standard hover/click/long-press patterns; never invent new ones
- `inter-microinteractions` - Always confirm interaction with visual feedback within 100ms
- `inter-keyboard-navigation` - Every interactive element must be reachable and operable by keyboard
- `inter-drag-drop` - Provide a keyboard-accessible alternative whenever drag is offered
- `inter-revalidation` - Use
revalidatePath/revalidateTagafter mutations; never rely on client refresh - `inter-row-actions` - Use a single "row action" pattern per list (kebab menu OR hover actions OR swipe)
- `inter-search` - Debounce search input by 200-300ms and reflect query in the URL
3. Accessibility (CRITICAL)
- `acc-labels` - Every interactive element has an accessible name
- `acc-text-scaling` - All text scales to 200% browser zoom without horizontal scroll
- `acc-color-contrast` - WCAG AA: 4.5:1 body text, 3:1 large/UI
- `acc-reduce-motion` - Respect
prefers-reduced-motion: reduce - `acc-color-independent` - Never rely on color alone to convey meaning
- `acc-focus-management` - Always render a visible focus ring; trap focus inside modals
- `acc-relative-units` - Use
remfor text and spacing; never fix text size inpx - `acc-responsive-layout` - Every layout works at 320 px width without horizontal scroll
4. User Feedback (HIGH)
- `feed-loading-states` - Always use
loading.tsxor<Suspense>with a skeleton matching final layout - `feed-error-states` - Every route segment has
error.tsxwith a Try-Again action - `feed-toasts` - Use toasts only for confirmations of non-blocking actions
- `feed-success-confirmation` - Confirm every destructive or irreversible action with explicit visible feedback
- `feed-empty-states` - Empty states explain why and offer the next action
5. UX Patterns (HIGH)
- `ux-onboarding` - Onboarding never exceeds 3 screens; always skippable
- `ux-permissions` - Request browser permissions in-context, not on page load
- `ux-modality` - Choose dialog / popover / full-page by content weight; never stack modals
- `ux-destructive-confirmation` - Destructive actions require a typed confirmation OR an undo window
- `ux-data-entry` - Use Server Actions + progressive enhancement; never disable submit while typing
- `ux-undo` - Prefer undo over confirmation for everyday actions
- `ux-settings` - Settings are autosaved on change; never gated behind a Save button
6. Visual Design (HIGH)
- `vis-dark-mode` - Use CSS custom properties +
dark:variant; never hardcodetext-black/bg-white - `vis-icon-system` - Use lucide-react with
1.5pxstroke andsize-4/size-5standard sizes - `vis-spacing` - Use the Tailwind 4 pt scale and container queries; never use ad-hoc
pxmargins
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
{{RULE_TITLE}}
{{1-3 sentences stating the Always/Never directive and why it matters.}}
Incorrect ({{what is wrong}}):
{{minimal bad example — same domain, same naming as the correct version}}Correct ({{what is right}}):
{{minimal good example — diff vs incorrect should be small and meaningful}}Rule:
- {{firm always/never directive}}
- {{measurable check / what to grep for in review}}
Reference: {{spec or doc title}}
{
"version": "1.0.0",
"organization": "React 19 + Next.js 16 + Tailwind CSS Strict Rules",
"technology": "React 19 / Next.js 16 (App Router) / Tailwind CSS 4",
"date": "May 2026",
"abstract": "Strict design and UX rules for web apps built with React 19, Next.js 16 (App Router), and Tailwind CSS 4. Modeled after Apple's HIG distillation: 33 rules across 6 categories (Navigation, Interaction Design, Accessibility, User Feedback, UX Patterns, Visual Design). Each rule states an Always/Never directive with a quantified impact metric, an incorrect example, and a correct example. Web-specific translations of HIG: tab bars become primary navigation, haptics become microinteractions, SF Symbols become lucide-react, NavigationStack becomes Next.js App Router. Targets React 19 features (Server Components, Actions, useFormState, useOptimistic) and Next.js 16 conventions (layout.tsx, loading.tsx, error.tsx, parallel routes).",
"references": [
"https://react.dev/reference/react",
"https://nextjs.org/docs/app",
"https://tailwindcss.com/docs",
"https://www.w3.org/WAI/WCAG22/quickref/",
"https://www.nngroup.com/articles/"
],
"category": "Design Guidelines",
"discipline": "distillation"
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Navigation (nav)
Impact: CRITICAL Description: Primary navigation, App Router structure, and page-action placement define how users move through your app. Navigation is the most fundamental UX topic — getting it wrong makes every screen harder to reach. In Next.js 16 the App Router shapes the entire app's mental model.
2. Interaction Design (inter)
Impact: CRITICAL Description: Pointer/touch targets, keyboard navigation, microinteractions, drag-and-drop, revalidation, row actions, and search define how users physically interact with your app. Getting these wrong breaks the platform feel and locks out keyboard and touch users.
3. Accessibility (acc)
Impact: CRITICAL Description: Labels, text scaling, color contrast, reduced motion, focus management, relative units, and responsive layout are not optional. WCAG 2.2 AA is the legal baseline in the EU (EAA, June 2025) and many US states. Failing accessibility excludes 20%+ of users.
4. User Feedback (feed)
Impact: HIGH Description: Loading states (Suspense + loading.tsx), error states (error.tsx + boundaries), toast notifications, success confirmation, and empty states communicate system status. With Server Components streaming, feedback quality determines perceived performance.
5. UX Patterns (ux)
Impact: HIGH Description: Onboarding, permission requests, modality (dialog/popover/sheet), destructive confirmation, data entry with Server Actions, undo, and settings organization follow established web patterns. Diverging from them costs users — even when the divergence is technically "better."
6. Visual Design (vis)
Impact: HIGH Description: Dark mode (Tailwind dark: + prefers-color-scheme), icon system (lucide-react sizing and stroke conventions), and spacing (Tailwind's 4pt scale and container queries) ensure your app looks native to the modern web and adapts to all viewports and themes.
Hit WCAG AA Contrast: 4.5:1 Body Text, 3:1 Large Text and UI Components
Body text (< 18 pt regular or < 14 pt bold) must have a contrast ratio of at least 4.5:1 against its background. Large text and non-text UI elements (icons, focus rings, form-field borders) need at least 3:1. Use semantic CSS variables and Tailwind tokens that have been audited — never pick colors by eyeballing. Test with the APCA contrast tool or Chrome DevTools' color picker.
Incorrect (low contrast — gray-400 on white = 2.85:1, fails AA):
<p className="text-gray-400">Description text on white background</p>
<button className="text-gray-300 border-gray-200">Submit</button>
{/* Form field placeholder using text-muted-foreground/40 — likely < 3:1 */}
<input placeholder="Search…" className="placeholder:text-muted-foreground/40" />Correct (semantic tokens with audited contrast):
// app/globals.css — semantic tokens defined once
@theme {
--color-foreground: oklch(0.15 0 0); /* near-black */
--color-muted-foreground: oklch(0.45 0 0); /* 4.61:1 on white — passes AA */
--color-background: oklch(1 0 0);
--color-border: oklch(0.85 0 0); /* 3.14:1 — passes UI AA */
--color-destructive: oklch(0.55 0.22 25); /* 4.5:1+ on bg, 3:1 on white text */
}
@theme dark {
--color-foreground: oklch(0.96 0 0);
--color-muted-foreground: oklch(0.65 0 0);
--color-background: oklch(0.13 0 0);
--color-border: oklch(0.28 0 0);
}
// Components reference tokens, not raw colors
<p className="text-muted-foreground">Description text</p>
<Button variant="destructive">Delete</Button>
<input className="border-border placeholder:text-muted-foreground" placeholder="Search…" />Validate with the in-skill script:
python scripts/verify_palette.py --foreground "#737373" --background "#FFFFFF"
# → Contrast ratio: 4.61:1 — PASSES AA for body textStatus colors must be color-independent too (see acc-color-independent):
// Error state — icon + text + token, not just red color
<div role="alert" className="flex items-center gap-2 text-destructive">
<AlertCircle className="size-4" aria-hidden="true" />
<span>Email is required</span>
</div>Rule:
- Body text ≥ 4.5:1 against its background; large text (≥ 18.66 px or ≥ 24 px bold) ≥ 3:1
- UI elements (borders, focus rings, icons) ≥ 3:1 against adjacent surfaces
- Define semantic tokens in
@themeand audit each one once; never define colors inline - Run
scripts/verify_palette.pyon each new color pair before shipping - Verify in both light and dark themes — many palettes pass one but fail the other
Reference: WCAG 1.4.3 Contrast (Minimum) · WCAG 1.4.11 Non-text Contrast
Never Rely on Color Alone to Convey Meaning
Status, validation, required fields, and chart series must communicate through redundant signals: an icon, text label, pattern, weight, position, or shape — in addition to color. The "required field is red" pattern fails for users with deuteranopia. The "error vs success in chart line color" fails the same way. Every color-coded affordance must remain legible in grayscale.
Incorrect (color is the only signal of state, required-ness, or selection):
// Form errors only signalled by red border + red text on a same-luminance background
<input
className={hasError ? 'border-red-500 text-red-500' : 'border-gray-300'}
/>
// Required field shown only by the label being red — invisible in grayscale
<label className="text-red-600">Email</label>
// Selected state only signalled by a slightly-darker background
<button className={selected ? 'bg-blue-100' : 'bg-white'}>{label}</button>
// Chart relies on color-only series
<svg><line stroke="red" /><line stroke="green" /></svg>Correct (redundant signals: icon + text + ARIA + position):
// Form error — icon + message + role + aria-invalid + token (not raw red)
<div className="space-y-1">
<label htmlFor="email" className="text-sm font-medium">
Email <span aria-hidden="true">*</span>
<span className="sr-only">(required)</span>
</label>
<input
id="email"
type="email"
required
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
className={cn('border', error && 'border-destructive')}
/>
{error && (
<p id="email-error" role="alert" className="flex items-center gap-1 text-sm text-destructive">
<AlertCircle className="size-4" aria-hidden="true" />
<span>{error}</span>
</p>
)}
</div>
// Selected state — outline + checkmark icon, not background alone
<button
aria-pressed={selected}
className={cn(
'flex items-center gap-2 rounded-md border px-3 h-11',
selected && 'border-2 border-primary bg-primary/10'
)}
>
{selected && <Check className="size-4" aria-hidden="true" />}
<span>{label}</span>
</button>
// Chart series — color + pattern + direct labels
<svg>
<line stroke="var(--color-chart-1)" strokeDasharray="0" />
<line stroke="var(--color-chart-2)" strokeDasharray="6 4" />
<text>Revenue</text>
<text>Cost</text>
</svg>Rule:
- Required fields: visual
*glyph +sr-only"(required)" + nativerequiredattribute - Validation errors: icon + text +
role="alert"+aria-invalid— not just colored border - Selected/active states: outline change OR icon glyph in addition to fill change
- Charts: pair color with shape, dash pattern, or direct labels (always provide a legend with text)
- Audit: switch the device to grayscale and verify every state remains distinguishable
Reference: WCAG 1.4.1 Use of Color
Render a Visible Focus Ring and Trap Focus Inside Modals
A focus ring must always be visible when keyboard navigation moves focus. Tailwind's focus-visible: variant shows the ring only for keyboard users (not pointer clicks). Modals must trap focus: Tab cycles only within the dialog and Shift+Tab wraps. When the modal closes, focus restores to the element that opened it. Use Radix Dialog / Popover — they implement all three behaviors correctly.
Incorrect (focus ring removed for all users, no focus trap, focus is lost on dialog close):
/* global.css */
*:focus { outline: none; } /* nukes accessibility for every keyboard user */function CustomDialog({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-0 bg-black/50">
<div className="bg-white p-6 rounded">
{children}
<button onClick={onClose}>Close</button>
{/* No focus trap. Tab can leave the dialog and reach the page behind. */}
</div>
</div>
)
}Correct (visible focus ring on keyboard, focus trap + restore via Radix):
/* app/globals.css */
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
border-radius: 4px;
}import * as Dialog from '@radix-ui/react-dialog'
function EditDialog({ children }: { children: React.ReactNode }) {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Edit</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content
className="fixed left-1/2 top-1/2 max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6 shadow-xl"
onOpenAutoFocus={(e) => {
// Default: focus first focusable element inside. Override only if necessary.
}}
>
{/* Radix Dialog:
- Traps focus inside Content while open
- Restores focus to the Trigger on close
- Adds aria-modal="true"
- Listens for Escape to close
*/}
<Dialog.Title className="text-lg font-semibold">Edit profile</Dialog.Title>
<form className="mt-4 space-y-3">
<label className="block">
<span className="text-sm font-medium">Name</span>
<input className="mt-1 w-full rounded-md border px-3 h-11 focus-visible:outline-2 focus-visible:outline-ring" />
</label>
<div className="flex justify-end gap-2">
<Dialog.Close asChild>
<Button variant="ghost">Cancel</Button>
</Dialog.Close>
<Button type="submit">Save</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}Routing-level focus restoration (App Router):
// app/template.tsx — fires on every navigation, unlike layout.tsx
'use client'
import { useEffect, useRef } from 'react'
export default function RouteFocus({ children }: { children: React.ReactNode }) {
const headingRef = useRef<HTMLDivElement>(null)
useEffect(() => {
headingRef.current?.focus()
}, [])
return (
<div ref={headingRef} tabIndex={-1} className="focus:outline-none">
{children}
</div>
)
}Rule:
- Never set
outline: nonewithout immediately providing a:focus-visiblereplacement - Use
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring(Tailwind) — the ring renders only on keyboard focus - Modals trap focus and restore it on close (use Radix Dialog / Popover / DropdownMenu — never roll your own)
- On route change, move focus to the new page's
<h1>(usetabIndex={-1}+.focus()intemplate.tsx) - Verify by Tab-walking the entire UI with the mouse unplugged
Reference: WCAG 2.4.7 Focus Visible · Radix Dialog focus management
Every Interactive Element Has an Accessible Name
Buttons, links, inputs, and form controls must have an accessible name announceable by screen readers. Prefer visible text labels — they help everyone. When only an icon is shown, add aria-label. For inputs, always pair with <label htmlFor>; never rely on placeholder as the label (it disappears on focus and has insufficient contrast).
Incorrect (icon-only button, placeholder-as-label, decorative image announced):
function Header() {
return (
<header>
<button onClick={() => setOpen(true)}>
<Menu /> {/* announced as "button" — what does it open? */}
</button>
<img src="/avatar.png" /> {/* announced as "graphic" — misleading */}
<input type="search" placeholder="Search" /> {/* no programmatic name */}
</header>
)
}Correct (every interactive element is named):
function Header({ user }: { user: User }) {
return (
<header>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Open navigation menu"
className="size-11 inline-flex items-center justify-center"
>
<Menu className="size-5" aria-hidden="true" />
</button>
<img
src={user.avatarUrl}
alt={`${user.name} avatar`}
/>
{/* If the image is decorative, use alt="" — not aria-hidden alone on <img> */}
<img src="/decorative-divider.svg" alt="" />
<label htmlFor="header-search" className="sr-only">Search projects</label>
<input
id="header-search"
type="search"
placeholder="Search projects…"
className="rounded-md border px-3 h-11"
/>
</header>
)
}Naming rules per element:
// Form field — visible label is best
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<input id="email" type="email" />
// Icon-only button — aria-label describes the action
<Button size="icon" aria-label="Delete project">
<Trash2 aria-hidden="true" className="size-4" />
</Button>
// Composite labels — combine with aria-labelledby
<section aria-labelledby="settings-heading">
<h2 id="settings-heading">Settings</h2>
...
</section>
// Live state — toggle pressed/unpressed
<Button
aria-pressed={pinned}
aria-label={pinned ? 'Unpin from top' : 'Pin to top'}
onClick={togglePin}
>
<Pin />
</Button>Rule:
- Every
<button>,<a>, and form control passes the accessible name computation — verify with the Accessibility panel in Chrome DevTools - Icon-only buttons must have
aria-label; decorative icons inside labelled buttons getaria-hidden="true" - Inputs always have a
<label htmlFor="...">(useclassName="sr-only"to hide visually when the visible UI relies on context) - Use
aria-pressedfor toggle buttons,aria-expandedfor disclosure triggers,aria-current="page"for active nav links placeholderis never the only label — it disappears on focus, has poor contrast, and isn't announced as the field's name
Reference: WCAG 4.1.2 Name, Role, Value
Respect prefers-reduced-motion: reduce
When the user has set prefers-reduced-motion: reduce, disable any non-essential animation: parallax, autoplay carousels, large translations, scale transforms, and decorative motion. Cross-fades and color transitions are fine. Tailwind ships a motion-safe: variant — apply transforms and translations only inside it, and keep transition-colors/transition-opacity always-on for state legibility.
Incorrect (animation runs for every user, no reduced-motion handling):
function HeroCard({ children }: { children: React.ReactNode }) {
return (
<div className="transition-transform duration-500 hover:translate-y-[-8px] hover:scale-105">
{children}
</div>
)
}
// Library animations that don't check prefers-reduced-motion
<motion.div animate={{ y: [0, -20, 0] }} transition={{ repeat: Infinity }}>
Pulsing badge
</motion.div>Correct (use `motion-safe:` for transforms, color/opacity transitions always-on):
function HeroCard({ children }: { children: React.ReactNode }) {
return (
<div className="transition-colors duration-150 motion-safe:transition-transform motion-safe:hover:-translate-y-2 motion-safe:hover:scale-[1.02]">
{children}
</div>
)
}
// framer-motion respects prefers-reduced-motion when you tell it to
import { useReducedMotion, motion } from 'framer-motion'
function PulsingBadge() {
const reduce = useReducedMotion()
return (
<motion.div
animate={reduce ? {} : { y: [0, -20, 0] }}
transition={{ repeat: Infinity, duration: 1.5 }}
>
New
</motion.div>
)
}CSS-level fallback (covers global keyframes you don't control directly):
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Rule:
- Transforms (
translate,scale,rotate) and large translations go undermotion-safe: - Color, opacity, and ≤ 150 ms cross-fade transitions are allowed always-on (they aid legibility)
- Library animations check
useReducedMotion()(framer-motion) or equivalent before animating - Never autoplay video or background animation; if it must autoplay, provide a Pause control
- Verify by toggling
Settings → Accessibility → Display → Reduce motion(macOS) or Chrome DevTools Rendering panel → "Emulate CSS media feature prefers-reduced-motion"
Reference: WCAG 2.3.3 Animation from Interactions
Use rem for Text and Spacing; Never Fix Text Size in px
rem is "root em" — sized relative to <html>'s font-size, which defaults to whatever the user has configured in their browser (often 16 px, but low-vision users frequently set it higher). All typography and spacing tokens should be rem-based. Tailwind's defaults already are. Use px only for things that genuinely shouldn't scale: 1 px borders, focus ring offsets, image dimensions.
Incorrect (text and spacing in px — ignores user's browser settings):
<article style={{ fontSize: '14px', padding: '16px' }}>
<h1 style={{ fontSize: '24px', marginBottom: '8px' }}>Title</h1>
<p style={{ fontSize: '14px', lineHeight: '20px' }}>Paragraph</p>
</article>Correct (rem-based text via Tailwind tokens):
<article className="text-sm p-4">
<h1 className="text-2xl font-semibold mb-2">Title</h1>
<p className="text-sm leading-relaxed">Paragraph</p>
</article>Tailwind unit conventions:
text-* → rem (responsive to user setting)
p-*, m-*, gap-* → rem (responsive)
size-*, h-*, w-* → rem (responsive)
border-* → px (intentional — 1px hairlines stay crisp)
ring-*, outline-* → px (intentional)Defining custom rem values:
/* app/globals.css */
@theme {
--spacing: 0.25rem; /* Tailwind's spacing unit */
--text-2xs: 0.625rem;
--text-display: 4rem;
/* 1rem === user's preferred body text size; everything scales together */
}Don't force `html { font-size: 14px }`:
/* INCORRECT — overrides the user's setting */
html { font-size: 14px; }
/* CORRECT — leave it alone, scale your design with rem multiples */When `px` IS the right unit:
<div className="border border-border" /> {/* 1px hairline — should stay 1px */}
<img width={64} height={64} src="..." /> {/* image intrinsic size */}
<svg width="20" height="20">...</svg> {/* icon viewBox — pair with size-5 Tailwind class */}Rule:
- Default to
remfor text, padding, margin, gap, width/height of text containers - Never hardcode
font-size: Npx— use Tailwind text utilities orremvalues - Don't override
html { font-size }— it scales the user's settings against them - Use
pxonly for: borders, focus offsets, image intrinsic dimensions, SVG viewBox - Test by changing browser default text size (Chrome → Settings → Appearance → Font size → Very Large) and verifying the UI scales
Reference: Use rem instead of px — Josh W Comeau
Every Layout Works at 320 px Width Without Horizontal Scroll
320 px is the WCAG 1.4.10 reflow target. At that width, content reflows to a single column; tables and figures with overflow-x-auto are exceptions. Use Tailwind's mobile-first breakpoints (sm:, md:, lg:) — start with the mobile layout and add wider-viewport variants. For components that should react to their own width (rather than the viewport's), use container queries (@container + @md:).
Incorrect (desktop-first layout, fixed-width content, no mobile breakpoint):
function Dashboard() {
return (
<div className="flex gap-8">
<aside className="w-64 shrink-0">Sidebar</aside>
<main className="w-[800px]">{/* fixed-width — overflows at 320px */}
<div className="grid grid-cols-3 gap-4">
{items.map((i) => <Card key={i.id} item={i} />)}
</div>
</main>
</div>
)
}Correct (mobile-first, fluid widths, breakpoints add complexity at wider sizes):
function Dashboard() {
return (
<div className="flex flex-col md:flex-row md:gap-8 md:p-6">
<aside className="md:w-64 md:shrink-0 border-b md:border-b-0 md:border-r">
<Sidebar />
</aside>
<main className="flex-1 min-w-0 p-4 md:p-0">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((i) => <Card key={i.id} item={i} />)}
</div>
</main>
</div>
)
}Container queries — when a component must adapt at its own size:
// Card adapts based on its parent's width, not the viewport
function Card({ item }: { item: Item }) {
return (
<article className="@container rounded-lg border p-4">
<div className="flex flex-col @md:flex-row @md:items-start gap-3">
<img src={item.image} className="w-full @md:w-24 aspect-video @md:aspect-square rounded" />
<div className="min-w-0">
<h3 className="font-semibold truncate">{item.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{item.description}</p>
</div>
</div>
</article>
)
}Tables — the legitimate exception (use `overflow-x-auto` and visible scroll hints):
<div className="overflow-x-auto rounded-md border" tabIndex={0} role="region" aria-label="Sales by month">
<table className="w-full text-sm">
<thead>...</thead>
<tbody>...</tbody>
</table>
</div>Rule:
- Mobile-first: write the smallest-viewport layout first, then add
sm:/md:/lg:modifiers - Never set
width: Npxon layout containers — usemax-w-*or fluid grid tracks - Always include
min-w-0on flex/grid children that contain text — prevents intrinsic-size overflow - Use container queries (
@container+@md:) when a component must react to its own width - Verify in Chrome DevTools "Toggle device toolbar" → set width to 320 — no horizontal scroll on the document
Reference: WCAG 1.4.10 Reflow · Container queries — Tailwind 4
All Text Scales to 200% Browser Zoom Without Horizontal Scroll
When a user zooms to 200%, every layout must reflow without producing horizontal scroll (except for explicitly horizontal content like data tables). The cause of failures is almost always: text sized in px instead of rem, fixed-width containers in px, or overflow: hidden swallowing the resized content. Use rem for typography and Tailwind's responsive utilities for layout.
Incorrect (fixed-px text, fixed-width container, overflow-hidden hides resized content):
function Card() {
return (
<div className="w-[400px] overflow-hidden" style={{ fontSize: '14px' }}>
<h2 style={{ fontSize: '18px' }}>Title</h2>
<p style={{ fontSize: '14px' }}>Body copy that won't grow.</p>
</div>
)
}Correct (rem-based text, fluid width, allows reflow):
function Card() {
return (
<div className="w-full max-w-md">
<h2 className="text-lg font-semibold">Title</h2>
<p className="text-sm text-muted-foreground">
Body copy that grows with the user's preferred text size.
</p>
</div>
)
}Tailwind text sizes are rem-based by default:
text-xs → 0.75rem (12px at default)
text-sm → 0.875rem (14px)
text-base → 1rem (16px) ← matches user's browser setting
text-lg → 1.125rem (18px)
text-xl → 1.25rem (20px)Container patterns that reflow correctly:
// Use min-h instead of h for content containers
<aside className="min-h-svh w-64 shrink-0">...</aside>
// Use grid with minmax for fluid columns
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-4">
<main className="min-w-0">{/* min-w-0 prevents overflow */}</main>
<aside>...</aside>
</div>
// Container queries when components must adapt at their own size
<div className="@container">
<div className="grid @md:grid-cols-2 gap-4">...</div>
</div>Rule:
- Never set
font-sizeinpx— use Tailwind's text sizes orremvalues - Never set fixed
widthinpxon text containers; usemax-w-*or fluid grid tracks min-w-0on flex/grid children that contain text — prevents the "intrinsic min-content" trap- Test at 200% browser zoom (Cmd/Ctrl +
=four times) and verify no horizontal scroll on the document - Use the Lighthouse accessibility audit "Document doesn't use 'user-scalable' meta tag" to catch zoom-blocking viewport configs
Reference: WCAG 1.4.4 Resize Text
Empty States Explain Why and Offer the Next Action
An empty state is not a missing UI — it's a moment with a job. There are three kinds: (1) first-run (the user has never created data), (2) no-results (search/filter returned nothing), (3) cleared (everything has been processed/dismissed). Each needs a different message and a primary action. Show the structure of what's missing — a skeleton, a sample preview, or the empty containers — not a generic "No data" line.
Incorrect (generic "No data" with no path forward):
function Projects({ projects }: { projects: Project[] }) {
if (projects.length === 0) {
return <p className="text-center p-12">No projects.</p>
}
return <ProjectList projects={projects} />
}
function SearchResults({ q, results }: { q: string; results: Item[] }) {
if (results.length === 0) return <p>No results.</p>
return <Results items={results} />
}Correct (first-run vs no-results vs cleared, each with the right action):
import { FolderPlus, SearchX, CheckCircle2 } from 'lucide-react'
// 1. FIRST-RUN — describe the value + primary CTA
function ProjectsEmpty() {
return (
<div className="mx-auto max-w-md text-center p-12 space-y-4">
<div className="mx-auto size-14 rounded-full bg-primary/10 flex items-center justify-center">
<FolderPlus className="size-7 text-primary" aria-hidden="true" />
</div>
<div>
<h2 className="text-lg font-semibold">Create your first project</h2>
<p className="mt-1 text-sm text-muted-foreground">
Projects organize your work and let you invite collaborators.
</p>
</div>
<Button asChild>
<Link href="/projects/new">New project</Link>
</Button>
</div>
)
}
// 2. NO-RESULTS — what was searched + how to broaden
function SearchEmpty({ query }: { query: string }) {
return (
<div className="mx-auto max-w-md text-center p-12 space-y-4">
<SearchX className="mx-auto size-10 text-muted-foreground" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold">No matches for "{query}"</h2>
<p className="mt-1 text-sm text-muted-foreground">
Check the spelling or try a shorter search.
</p>
</div>
<Button variant="outline" asChild>
<Link href="?">Clear search</Link>
</Button>
</div>
)
}
// 3. CLEARED — celebrate, and offer the next sensible action
function InboxCleared() {
return (
<div className="mx-auto max-w-md text-center p-12 space-y-4">
<CheckCircle2 className="mx-auto size-10 text-success" aria-hidden="true" />
<h2 className="text-lg font-semibold">Inbox zero</h2>
<p className="text-sm text-muted-foreground">Nothing left to handle. Nice work.</p>
<Button variant="ghost" size="sm" asChild>
<Link href="/archive">View archived</Link>
</Button>
</div>
)
}Rule:
- Choose the right type: first-run (explain value + CTA), no-results (refine query), cleared (celebrate + next destination)
- Icon + heading + 1-2 sentences + primary action — in that order
- Heading is a noun or imperative ("Create your first project"), not a negation ("No data")
- The primary CTA does the most likely next thing; secondary CTA (if any) gives an alternate path
- Use the success icon for "cleared", a muted icon for "no results", and the primary color icon for "first-run"
Reference: Empty states UX — NN/g
Every Route Segment Has error.tsx With a Try-Again Action
Each route segment that fetches data or runs Server Actions must include an error.tsx. The component receives error and reset; render a short human explanation plus a Try again button that calls reset(). For Server Action errors, return structured error state from the action and render inline near the field. Never display a raw stack trace to end users.
Incorrect (no error boundary; raw error message; no recovery path):
// app/projects/page.tsx
export default async function Projects() {
const projects = await getProjects() // throws if API is down
// No error.tsx — Next.js falls back to the global error page and nukes the layout
return <ProjectList projects={projects} />
}Correct (route-level error boundary + Try-Again, action-level structured errors):
// app/projects/error.tsx
'use client'
import { AlertCircle } from 'lucide-react'
import { useEffect } from 'react'
export default function ProjectsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log to your observability tool (Sentry, Datadog, etc.) — include digest
console.error('Projects error', error)
}, [error])
return (
<div role="alert" className="mx-auto max-w-md text-center p-12 space-y-4">
<AlertCircle className="mx-auto size-10 text-destructive" aria-hidden="true" />
<h2 className="text-lg font-semibold">We couldn't load your projects</h2>
<p className="text-sm text-muted-foreground">
This is usually temporary. Try again — if it keeps happening, contact support.
</p>
<Button onClick={reset} className="mt-2">Try again</Button>
{error.digest && (
<p className="text-xs text-muted-foreground">Reference: {error.digest}</p>
)}
</div>
)
}Server Action error pattern (validation + recoverable failures):
// app/projects/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({ name: z.string().min(1, 'Name is required') })
export async function createProject(_prev: unknown, formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }
try {
await db.project.create({ data: parsed.data })
revalidateTag('projects')
return { ok: true }
} catch (e) {
return { ok: false, formError: 'Something went wrong. Please try again.' }
}
}
// app/projects/new-project-form.tsx
'use client'
import { useActionState } from 'react'
export function NewProjectForm() {
const [state, action, pending] = useActionState(createProject, { ok: false })
return (
<form action={action} className="space-y-2">
<input name="name" required aria-invalid={!!state.fieldErrors?.name} />
{state.fieldErrors?.name && (
<p role="alert" className="text-sm text-destructive">{state.fieldErrors.name[0]}</p>
)}
{state.formError && (
<p role="alert" className="text-sm text-destructive">{state.formError}</p>
)}
<Button type="submit" disabled={pending}>{pending ? 'Creating…' : 'Create'}</Button>
</form>
)
}Rule:
- Every route segment that fetches data has an
error.tsx(Client Component, marked'use client') - The error component shows: a human message, a
Try againbutton callingreset(), and theerror.digestfor support - Server Actions return structured error state (
{ ok, fieldErrors, formError }) — never throw uncaught - Validation errors render inline with
role="alert"andaria-invalid - Log to your observability tool from within
error.tsx'suseEffect— include user/session context
Reference: Error handling — Next.js 16
Use loading.tsx or <Suspense> With a Skeleton That Matches Final Layout
Every route segment that fetches data has a loading.tsx that renders a skeleton matching the final layout's shape. For data fetched lower in the tree, wrap the slow component in <Suspense> with a tailored fallback so the rest of the page streams in immediately. Generic centered spinners are a fallback of last resort — they tell the user "something is happening" but nothing about what.
Incorrect (whole-page spinner, no skeleton, no streaming):
// app/projects/[id]/page.tsx
'use client'
export default function ProjectPage({ params }: { params: { id: string } }) {
const [project, setProject] = useState<Project | null>(null)
useEffect(() => {
fetch(`/api/projects/${params.id}`).then((r) => r.json()).then(setProject)
}, [params.id])
if (!project) return <Loader2 className="size-12 animate-spin mx-auto mt-32" />
return <ProjectDetail project={project} />
}Correct (Server Component + loading.tsx + Suspense for nested slow data):
// app/projects/[id]/page.tsx — Server Component, awaits at the top
import { Suspense } from 'react'
import { getProject } from '@/lib/data'
export default async function ProjectPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const project = await getProject(id) // fast — main content
return (
<article>
<ProjectHeader project={project} />
<Suspense fallback={<ActivitySkeleton />}>
<ProjectActivity projectId={id} /> {/* slow — streams in */}
</Suspense>
</article>
)
}
// app/projects/[id]/loading.tsx — shape-matching skeleton, not a spinner
export default function Loading() {
return (
<article aria-busy="true" aria-label="Loading project">
<header className="flex items-center gap-4 p-6">
<div className="size-12 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-5 w-48 rounded bg-muted animate-pulse" />
<div className="h-4 w-32 rounded bg-muted animate-pulse" />
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 rounded-md bg-muted animate-pulse" />
))}
</div>
</article>
)
}
function ActivitySkeleton() {
return (
<div className="p-6 space-y-3" aria-label="Loading activity">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 rounded bg-muted animate-pulse" />
))}
</div>
)
}Suspense around any awaited Server Component you'd like to stream:
export default function Page() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<CardSkeleton />}>
<SlowCardA /> {/* awaited inside the component */}
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<SlowCardB />
</Suspense>
</div>
)
}Rule:
- Every route segment that awaits data has a
loading.tsx— Next.js auto-wraps the page in<Suspense> - Skeletons match the shape of the final UI (cards, rows, headers) — not generic spinners
- Skeleton elements use
animate-pulseand thebg-mutedtoken (subtle, theme-aware) - Add
aria-busy="true"oraria-label="Loading X"on the skeleton container so screen readers announce loading - Slow data nested below the page-level
loading.tsxgets its own<Suspense>boundary
Reference: Loading UI and Streaming — Next.js 16
Confirm Every Destructive or Irreversible Action With Explicit Visible Feedback
After any destructive or hard-to-undo action (delete, archive, send, pay), confirm completion with visible feedback that names the action. For everyday actions, prefer optimistic UI + toast-with-undo. For high-stakes irreversible actions (purchase, public publish), show a confirmation screen or banner that persists until the user dismisses it. Never assume "no error = obvious success."
Incorrect (silent action — user wonders if anything happened):
function ArchiveButton({ id }: { id: string }) {
return (
<button onClick={() => archiveAction(id)}>
Archive
</button>
)
// Action succeeds, page reloads silently. User: "did I actually archive it?"
}Correct (optimistic update + named toast + undo for everyday actions):
'use client'
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { archiveAction, unarchiveAction } from './actions'
export function ProjectRow({ project }: { project: Project }) {
const [optimisticArchived, setOptimisticArchived] = useOptimistic(project.archived)
const [, startTransition] = useTransition()
function onArchive() {
startTransition(async () => {
setOptimisticArchived(true)
const result = await archiveAction(project.id)
if (!result.ok) {
setOptimisticArchived(false)
toast.error(`Couldn't archive "${project.name}"`)
return
}
toast.success(`Archived "${project.name}"`, {
action: { label: 'Undo', onClick: () => unarchiveAction(project.id) },
duration: 6000,
})
})
}
return (
<li className="flex items-center justify-between p-3">
<span className={optimisticArchived ? 'text-muted-foreground line-through' : ''}>
{project.name}
</span>
<Button variant="ghost" size="sm" onClick={onArchive} disabled={optimisticArchived}>
Archive
</Button>
</li>
)
}High-stakes confirmation (purchase / publish / send) — persistent banner or screen:
// After successful checkout — render a confirmation screen, not just a toast
export default async function ConfirmationPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const order = await getOrder(id)
return (
<div className="mx-auto max-w-md text-center p-8 space-y-4">
<CheckCircle2 className="mx-auto size-12 text-success" aria-hidden="true" />
<h1 className="text-xl font-semibold">Order confirmed</h1>
<p className="text-sm text-muted-foreground">
Order #{order.number} for {formatMoney(order.total)} is on its way.
We sent the receipt to {order.email}.
</p>
<div className="flex justify-center gap-2 pt-2">
<Button variant="ghost" asChild><Link href="/orders">View orders</Link></Button>
<Button asChild><Link href="/">Back to shop</Link></Button>
</div>
</div>
)
}Rule:
- Every destructive action triggers a named confirmation (toast or screen) — the message includes the entity name ("Archived 'Atlas project'", not "Archived")
- Everyday actions (archive, delete, mute): toast + Undo, 6 s duration
- Irreversible high-stakes actions (purchase, send, publish): confirmation screen or banner that persists until dismissed
- Pair confirmation with
aria-live="polite"regions so screen-reader users hear it (sonnerhandles this) - Never rely on the absence of an error to imply success
Reference: Affordances and signifiers — NN/g
Use Toasts Only for Confirmations of Non-Blocking Actions
Toasts (transient banners) are for confirmations of completed actions the user just initiated — "Project saved", "Invite sent", "Copied to clipboard". They are not for: validation errors (render inline), blocking failures (show in the page), or important alerts the user must read (use a dialog). Use sonner — it ships proper ARIA live regions and supports rich, action-bearing toasts.
Incorrect (toasts used for blocking errors, validation, and important alerts):
'use client'
import { toast } from 'sonner'
function PaymentForm() {
return (
<form action={async (formData) => {
const result = await pay(formData)
if (!result.ok) {
toast.error('Payment failed') // blocking error hidden in toast that auto-dismisses
return
}
toast.success('Done')
}}>
...
</form>
)
}
// Toasting validation errors instead of rendering them inline
if (email === '') toast.error('Email is required')Correct (toasts for completed non-blocking actions only; blocking errors rendered in-page):
// app/layout.tsx — Toaster mounted once at the root
import { Toaster } from 'sonner'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Toaster position="bottom-right" richColors />
</body>
</html>
)
}
// Non-blocking confirmation — toast is appropriate
'use client'
import { toast } from 'sonner'
export function CopyLinkButton({ url }: { url: string }) {
return (
<Button
variant="ghost"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(url)
toast.success('Link copied')
}}
>
<Link2 className="mr-2 size-4" /> Copy link
</Button>
)
}
// Toast with an Undo action — pair with optimistic UI
import { toast } from 'sonner'
import { archiveAction, unarchiveAction } from './actions'
async function onArchive(id: string) {
await archiveAction(id)
toast.success('Project archived', {
action: { label: 'Undo', onClick: () => unarchiveAction(id) },
duration: 6000,
})
}
// Blocking failure → render in the page, not as a toast
{state.error && (
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{state.error}
</div>
)}Rule:
- Toasts: completed non-blocking actions only — saves, copies, sends, archives
- Validation errors: inline with the field,
role="alert" - Blocking failures: in-page alert region or full error state
- Toast duration ≥ 4 s for read-only confirmations; ≥ 6 s when paired with an Undo action
- Maximum one toast on screen at a time —
sonnerqueues by default - Always-on dark/light theming via
<Toaster richColors />— never use raw red/green outside the design tokens
Reference: sonner docs · Toast — ARIA Authoring Practices
Provide a Keyboard-Accessible Alternative Whenever Drag Is Offered
Drag-and-drop is fine, but never the only way to perform an action. Use @dnd-kit/core because it ships built-in keyboard support (Space to pick up, arrows to move, Space again to drop, Escape to cancel) and screen-reader live-region announcements. For simple reorder lists, also provide "Move up" / "Move down" buttons in a row's overflow menu.
Incorrect (HTML5 native DnD — keyboard inaccessible, no fallback):
function SortableList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li
key={item.id}
draggable
onDragStart={(e) => e.dataTransfer.setData('id', item.id)}
onDrop={(e) => moveItem(e.dataTransfer.getData('id'), item.id)}
onDragOver={(e) => e.preventDefault()}
>
{item.name}
</li>
))}
</ul>
)
}Correct (@dnd-kit with keyboard sensor + visible drag handle + overflow alternative):
'use client'
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { SortableContext, useSortable, sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical } from 'lucide-react'
function SortableRow({ item, onMoveUp, onMoveDown }: {
item: Item; onMoveUp: () => void; onMoveDown: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: item.id })
return (
<li
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className="flex items-center gap-2 p-2"
>
<button
{...attributes}
{...listeners}
aria-label={`Reorder ${item.name}`}
className="size-11 inline-flex items-center justify-center cursor-grab"
>
<GripVertical className="size-4" />
</button>
<span className="flex-1">{item.name}</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" aria-label={`Actions for ${item.name}`}>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={onMoveUp}>Move up</DropdownMenuItem>
<DropdownMenuItem onSelect={onMoveDown}>Move down</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
)
}
export function SortableList({ items, setItems }: { items: Item[]; setItems: (i: Item[]) => void }) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
return (
<DndContext
sensors={sensors}
onDragEnd={({ active, over }) => {
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((i) => i.id === active.id)
const newIndex = items.findIndex((i) => i.id === over.id)
setItems(arrayMove(items, oldIndex, newIndex))
}
}}
>
<SortableContext items={items.map((i) => i.id)}>
<ul>
{items.map((item, i) => (
<SortableRow
key={item.id}
item={item}
onMoveUp={() => i > 0 && setItems(arrayMove(items, i, i - 1))}
onMoveDown={() => i < items.length - 1 && setItems(arrayMove(items, i, i + 1))}
/>
))}
</ul>
</SortableContext>
</DndContext>
)
}Rule:
- Always use a library (
@dnd-kit/core,react-ariauseDrop) — never raw HTML5draggable - Drag handle is a visible, focusable element with
aria-label - A keyboard-only path exists ("Move up" / "Move down" or arrow-key reorder)
- After drop, announce the new position via live region (dnd-kit does this automatically when
announcementsare configured) - Persist the new order via Server Action; revalidate the route after success
Reference: WCAG 2.5.7 Dragging Movements · @dnd-kit accessibility
Every Interactive Element Is Reachable and Operable by Keyboard
Tab moves focus forward through interactive elements in DOM order; Shift+Tab moves back. Enter activates buttons and submits forms; Space activates buttons and toggles checkboxes; Escape closes dialogs, popovers, and dropdowns. Arrow keys navigate within composite widgets (lists, menus, tabs, radio groups). Never use <div onClick> for actions — it is invisible to keyboards and assistive tech.
Incorrect (`<div>` actions, no Escape handler, focus stuck inside dialog):
function FilterPanel({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null
return (
<div className="fixed inset-0 bg-background p-6">
<div onClick={onClose}>×</div> {/* unreachable by Tab, no Enter handler */}
<div onClick={applyFilter}>Apply</div> {/* same problem */}
</div>
)
}Correct (semantic elements, Escape closes, Radix dialog manages focus + trap + restore):
import * as Dialog from '@radix-ui/react-dialog'
function FilterPanel({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40 data-[state=open]:animate-in" />
<Dialog.Content
className="fixed inset-x-4 top-1/2 -translate-y-1/2 max-w-md rounded-lg bg-background p-6 shadow-lg"
// Radix handles: focus trap, Escape to close, restore focus on close, aria-modal
>
<Dialog.Title className="text-lg font-semibold">Filters</Dialog.Title>
<fieldset className="mt-4 space-y-2">
{/* ... form controls — every one is focusable */}
</fieldset>
<div className="mt-6 flex justify-end gap-2">
<Dialog.Close asChild>
<Button variant="ghost">Cancel</Button>
</Dialog.Close>
<Button onClick={applyFilter}>Apply</Button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}Composite widget keyboard contract:
// Tabs — Radix handles ArrowLeft/ArrowRight, Home, End
<Tabs.Root defaultValue="account">
<Tabs.List>
<Tabs.Trigger value="account">Account</Tabs.Trigger>
<Tabs.Trigger value="security">Security</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="account">...</Tabs.Content>
<Tabs.Content value="security">...</Tabs.Content>
</Tabs.Root>Rule:
- Use semantic elements (
<button>,<a>,<input>,<select>) — never<div onClick> - Tab order matches visual order — never reorder with positive
tabindex(onlytabindex="-1"andtabindex="0"are allowed) - Escape closes every dismissable surface (dialog, popover, dropdown, menu)
- Use Radix or shadcn/ui primitives for composite widgets — they implement the full ARIA APG patterns
- Verify by tabbing through every screen with the mouse unplugged
Reference: WCAG 2.1.1 Keyboard · ARIA Authoring Practices Guide
Confirm Every Interaction With Visual Feedback Within 100 ms
Every click, focus, hover, or form submission must produce a visible change in under 100 ms. For server-bound actions, use React 19's useOptimistic to render the expected result immediately; reconcile when the action resolves. Disabled-but-pending submit buttons must show a spinner, never a frozen state. Hover and active states use transition-colors duration-150; abrupt changes feel cheap.
Incorrect (no pending state, button "freezes" while waiting on server):
'use client'
import { useState } from 'react'
function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
const [liked, setLiked] = useState(initialLiked)
return (
<button
onClick={async () => {
await toggleLike(postId) // 400 ms round-trip — UI shows nothing
setLiked(!liked)
}}
>
<Heart className={liked ? 'fill-rose-500' : ''} />
</button>
)
}Correct (optimistic update + transition + pending indicator):
'use client'
import { useOptimistic, useTransition } from 'react'
function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(initialLiked)
const [pending, startTransition] = useTransition()
return (
<button
onClick={() => {
startTransition(async () => {
setOptimisticLiked(!optimisticLiked) // < 16 ms — next paint
await toggleLikeAction(postId)
})
}}
className="inline-flex size-11 items-center justify-center rounded-full transition-colors duration-150 hover:bg-rose-50 active:scale-95"
aria-pressed={optimisticLiked}
aria-label={optimisticLiked ? 'Unlike' : 'Like'}
>
<Heart
className={`size-5 transition-colors duration-150 ${
optimisticLiked ? 'fill-rose-500 text-rose-500' : 'text-muted-foreground'
} ${pending ? 'animate-pulse' : ''}`}
/>
</button>
)
}Form submit pattern:
'use client'
import { useFormStatus } from 'react-dom'
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}
{children}
</Button>
)
}Rule:
- Use
useOptimisticfor any user-initiated mutation that has a predictable outcome - Use
useFormStatusto show pending state inside form submit buttons - Every interactive element has
:hover,:focus-visible, and:activestyles - Standard transition:
transition-colors duration-150; never exceedduration-300for state changes - Respect acc-reduce-motion — disable transforms and translations when reduced motion is requested
Reference: Response Times: The 3 Important Limits — Nielsen Norman Group
Use Standard Hover, Click, and Long-Press Patterns; Never Invent New Ones
Click = primary action. Right-click (or long-press on touch) = context menu. Hover = reveal secondary details or actions on desktop, never hide primary functionality. Double-click is reserved for <input> text editing and the OS-level "open" gesture in file managers — never invent custom double-click handlers. Long-press is the touch equivalent of right-click; pair them in a single component.
Incorrect (primary action only on hover, custom double-tap, no touch equivalent):
function Row({ item }: { item: Item }) {
const [hovered, setHovered] = useState(false)
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onDoubleClick={() => openItem(item.id)} // discoverable to ~0% of users
>
{item.name}
{hovered && <button onClick={() => deleteItem(item.id)}>Delete</button>}
</div>
)
}Correct (click opens, right-click and long-press both open context menu, hover shows actions but they are reachable by keyboard too):
import * as ContextMenu from '@radix-ui/react-context-menu'
function Row({ item }: { item: Item }) {
return (
<ContextMenu.Root>
<ContextMenu.Trigger asChild>
<Link
href={`/items/${item.id}`}
className="group flex items-center justify-between px-3 py-2 hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
>
<span>{item.name}</span>
{/* Hover-reveal actions, but also keyboard-focusable */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={`Actions for ${item.name}`}
className="invisible group-hover:visible group-focus-within:visible size-8 flex items-center justify-center"
>
<MoreHorizontal className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => openItem(item.id)}>Open</DropdownMenuItem>
<DropdownMenuItem onSelect={() => deleteItem(item.id)} className="text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Link>
</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Content className="min-w-40 rounded-md border bg-popover p-1 shadow-md">
<ContextMenu.Item onSelect={() => openItem(item.id)}>Open</ContextMenu.Item>
<ContextMenu.Item onSelect={() => deleteItem(item.id)}>Delete</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu.Root>
)
}Rule:
- Primary action is always reachable in one click — never require hover or double-click to discover
- Right-click and long-press open the same context menu (Radix
ContextMenuhandles both) - Hover-revealed UI must also appear on
:focus-withinso keyboard users see the same affordances - Use
group+group-hover:+group-focus-within:(Tailwind) for hover/focus reveal patterns - Reserve double-click for rename-in-place text inputs only
Reference: Discoverable Functionality — Nielsen Norman Group
Revalidate Cache After Mutations With revalidatePath / revalidateTag; Never Rely on Client Refresh
Server Actions that mutate data must invalidate the affected cache entries on the server. Use revalidatePath('/projects') for path-based invalidation or revalidateTag('project-list') for tag-based fan-out across multiple routes. Pair with useOptimistic on the client for instant UI feedback. Never call router.refresh() from the client to "fix" stale data — it papers over the real problem and ships a full re-fetch every time.
Incorrect (client refresh after mutation, no cache invalidation on the server):
// app/projects/actions.ts
'use server'
export async function createProject(formData: FormData) {
await db.project.create({ data: { name: formData.get('name') as string } })
// returns silently — cache stays stale
}
// app/projects/new-project-form.tsx
'use client'
import { useRouter } from 'next/navigation'
export function NewProjectForm() {
const router = useRouter()
return (
<form
action={async (formData) => {
await createProject(formData)
router.refresh() // wasteful full re-fetch
}}
>
<input name="name" />
<button type="submit">Create</button>
</form>
)
}Correct (server-side revalidation + optimistic UI):
// app/projects/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createProject(formData: FormData) {
const name = formData.get('name') as string
if (!name?.trim()) return { error: 'Name is required' }
const project = await db.project.create({ data: { name } })
revalidateTag('project-list') // refreshes all routes tagged 'project-list'
redirect(`/projects/${project.id}`) // navigate to the new entity
}
// app/projects/page.tsx — fetch with cache tag
export default async function Projects() {
const projects = await fetch('/api/projects', { next: { tags: ['project-list'] } }).then((r) => r.json())
return <ProjectList projects={projects} />
}
// app/projects/new-project-form.tsx
'use client'
import { useActionState } from 'react'
import { createProject } from './actions'
export function NewProjectForm() {
const [state, formAction, pending] = useActionState(createProject, null)
return (
<form action={formAction} className="space-y-2">
<input name="name" required className="w-full rounded border px-3 py-1.5" />
{state?.error && <p className="text-sm text-destructive">{state.error}</p>}
<Button type="submit" disabled={pending}>{pending ? 'Creating…' : 'Create'}</Button>
</form>
)
}Rule:
- Every Server Action that writes calls
revalidatePath/revalidateTag(orredirect, which implicitly revalidates) - Tag every cached
fetch()with{ next: { tags: [...] } }so you can invalidate granularly - Never call
router.refresh()as the primary stale-data fix — only as a last-resort escape hatch - For optimistic UX, combine
useOptimistic(client) with server revalidation — never rely on optimistic state alone
Reference: Data fetching, caching, and revalidating — Next.js 16
Use One Row-Action Pattern Per List (Kebab Menu OR Hover Actions OR Swipe)
Pick one row-action pattern for a given list and stick with it. The three valid patterns: (1) a kebab menu (…) at the end of every row, always visible; (2) hover-reveal action icons that also appear on :focus-within; (3) swipe-to-reveal on mobile, paired with the kebab on desktop. Never mix patterns inside the same list. The kebab pattern is the safest default — visible, keyboard-reachable, mobile-friendly without swipe gestures.
Incorrect (mixing inline buttons, hover icons, and "click anywhere to delete" handlers):
function MessageList({ messages }: { messages: Message[] }) {
return (
<ul>
{messages.map((m) => (
<li key={m.id} className="flex gap-2" onClick={() => deleteMessage(m.id)}>
<span>{m.subject}</span>
<button onClick={() => archiveMessage(m.id)}>Archive</button>
{/* and elsewhere in the same list, hover-only actions on other rows */}
</li>
))}
</ul>
)
}Correct (single kebab-menu pattern, always visible, keyboard-reachable):
import { MoreHorizontal, Archive, Star, Trash2 } from 'lucide-react'
function MessageRow({ message }: { message: Message }) {
return (
<li className="group flex items-center gap-3 px-3 py-2 hover:bg-accent">
<Link href={`/inbox/${message.id}`} className="flex-1 truncate">
{message.subject}
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-9"
aria-label={`Actions for ${message.subject}`}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => archiveAction(message.id)}>
<Archive className="mr-2 size-4" /> Archive
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => starAction(message.id)}>
<Star className="mr-2 size-4" /> Star
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => deleteAction(message.id)} className="text-destructive">
<Trash2 className="mr-2 size-4" /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
)
}Alternative (hover-reveal pattern — only when the row itself is dense and chrome must stay quiet):
// Reveals the same actions on hover OR focus-within. Same kebab as fallback on touch.
<li className="group flex items-center gap-3 px-3 py-2 hover:bg-accent">
<Link href={`/inbox/${m.id}`} className="flex-1 truncate">{m.subject}</Link>
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<IconButton aria-label="Archive" icon={Archive} onClick={() => archiveAction(m.id)} />
<IconButton aria-label="Star" icon={Star} onClick={() => starAction(m.id)} />
</div>
</li>Rule:
- Pick one pattern per list (kebab is the default) and do not mix
- Row click navigates to detail — never deletes, archives, or otherwise mutates
- Hover-reveal must also appear on
:focus-within(use Tailwindgroup-focus-within:) - Destructive items in the menu are marked with
text-destructiveand grouped at the bottom under a separator - Swipe actions (mobile) must duplicate every action available in the kebab menu — never expose a destructive action by swipe only
Reference: List item interactions — Material Design
Debounce Search Input by 200-300 ms and Reflect Query in the URL
Search input must be debounced (200-300 ms after the user stops typing) before triggering a network request. The query string lives in ?q=… so back/forward, refresh, sharing, and SSR all work. Use useDeferredValue or a custom useDebounce hook on the client; on the server, read searchParams and pass it into the data fetch. Render the search input as a Client Component, but render the results as a Server Component when possible.
Incorrect (every keystroke = network call, no URL state):
'use client'
function Search() {
const [q, setQ] = useState('')
const [results, setResults] = useState<Item[]>([])
return (
<>
<input
value={q}
onChange={async (e) => {
setQ(e.target.value)
const r = await fetch(`/api/search?q=${e.target.value}`).then((r) => r.json())
setResults(r) // race conditions, no debounce, no URL state
}}
/>
<Results results={results} />
</>
)
}Correct (URL-driven, debounced, server-rendered results):
// app/search/page.tsx — Server Component
import { SearchInput } from './search-input'
import { SearchResults } from './search-results'
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q = '' } = await searchParams
return (
<div className="space-y-4">
<SearchInput defaultValue={q} />
<SearchResults query={q} />
</div>
)
}
// app/search/search-input.tsx
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { useDebouncedCallback } from 'use-debounce'
import { Search } from 'lucide-react'
export function SearchInput({ defaultValue }: { defaultValue: string }) {
const router = useRouter()
const pathname = usePathname()
const params = useSearchParams()
const update = useDebouncedCallback((value: string) => {
const next = new URLSearchParams(params)
if (value) next.set('q', value)
else next.delete('q')
router.replace(`${pathname}?${next.toString()}`, { scroll: false })
}, 250)
return (
<label className="relative block">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<input
type="search"
defaultValue={defaultValue}
onChange={(e) => update(e.target.value)}
placeholder="Search projects…"
aria-label="Search projects"
className="w-full rounded-md border bg-background pl-10 pr-3 h-11 text-sm"
/>
</label>
)
}
// app/search/search-results.tsx — Server Component, re-runs when ?q changes
export async function SearchResults({ query }: { query: string }) {
if (!query.trim()) return null
const results = await searchProjects(query)
if (results.length === 0) return <EmptyState query={query} />
return (
<ul className="divide-y">
{results.map((r) => (
<li key={r.id}>
<Link href={`/projects/${r.id}`} className="flex h-11 items-center px-3 hover:bg-accent">
{r.name}
</Link>
</li>
))}
</ul>
)
}Rule:
- Debounce by 200-300 ms (250 is the sweet spot); never fire on every keystroke
- Search query lives in
searchParams(?q=…) — neveruseStatealone - Use
router.replace(..., { scroll: false })so the back button doesn't fill with intermediate keystrokes - Wrap the results component in
<Suspense>keyed byqso streaming + cancellation work - The input is
type="search"(gives users a clear-button), with a visiblearia-label
Reference: searchParams in Next.js App Router
Maintain 44×44 px Minimum Touch Targets (WCAG 2.5.5)
Every interactive element must have a hit area of at least 44×44 CSS pixels, even when the visible affordance is smaller. Use padding (or min-h-11 min-w-11 in Tailwind 4 — h-11 = 44 px) to expand the hit area without changing the visual size. Inline icon buttons in dense lists are the most common offender; wrap them in a <button> with the required size.
Incorrect (icon button has only ~16×16 px hit area):
<button onClick={onClose} className="text-muted-foreground">
<X className="size-4" />
</button>Correct (icon button has 44×44 px hit area while staying visually compact):
<button
onClick={onClose}
aria-label="Close"
className="inline-flex size-11 items-center justify-center rounded-md text-muted-foreground hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
>
<X className="size-4" />
</button>Common patterns:
// shadcn/ui Button with size="icon" already uses size-9 (36px) — bump to size="icon-lg"
<Button size="icon" className="size-11">
<Plus className="size-4" />
</Button>
// Checkbox row — entire label is the hit area
<label className="flex min-h-11 items-center gap-3 px-3 cursor-pointer">
<Checkbox checked={selected} onCheckedChange={setSelected} />
<span>{label}</span>
</label>Rule:
min-h-11 min-w-11(44 px) on every standalone interactive control- Adjacent targets need ≥ 8 px gap so users can hit each without misfire
- Hit area > visual area is fine — use transparent padding, not visible margin
- Verify with the Chrome DevTools "Tap targets" Lighthouse audit before shipping
Reference: WCAG 2.5.5 Target Size (Enhanced)
Use App Router Layouts, Route Groups, and <Link> for All Internal Navigation
In Next.js 16 every navigation between internal pages goes through <Link> from next/link. Layouts (layout.tsx) hold shared chrome (header, sidebar, footer) and re-render only when their segment changes. Nested layouts compose; do not duplicate chrome across routes. Use parallel routes (@slot) for independent panels (modal + page, list + detail) and intercepting routes ((..)slug) for "open as modal" patterns.
Incorrect (raw `<a>`, duplicated chrome, `useRouter().push` for visible links):
// app/projects/page.tsx — duplicates the header on every page
export default function Projects() {
const router = useRouter()
return (
<>
<Header />
<a href="/projects/new">New project</a>
<button onClick={() => router.push('/projects/123')}>Open project</button>
</>
)
}Correct (layout owns chrome, `<Link>` for all visible navigation):
// app/projects/layout.tsx
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-[16rem_1fr] h-dvh">
<ProjectsSidebar />
<main className="overflow-auto">{children}</main>
</div>
)
}
// app/projects/page.tsx
import Link from 'next/link'
export default async function Projects() {
const projects = await getProjects() // Server Component — data fetched on server
return (
<>
<header className="flex justify-between items-center p-6">
<h1 className="text-xl font-semibold">Projects</h1>
<Link
href="/projects/new"
className="rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground"
>
New project
</Link>
</header>
<ul>
{projects.map((p) => (
<li key={p.id}>
<Link href={`/projects/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</>
)
}Rule:
- Always import
Linkfromnext/linkfor internal hrefs; reserve<a>for external links (and addrel="noopener noreferrer"withtarget="_blank") - Never call
router.push()for navigation a user would otherwise click — keepuseRouterfor programmatic flows (post-action redirects, auth) - Shared chrome lives in
layout.tsxat the deepest segment it applies to - Use parallel routes for independent loading states (e.g.,
@modal+ page) - Use
loading.tsxanderror.tsxat every route segment that fetches data
Reference: App Router · Next.js 16
Place Primary Page Actions in the Page Header; Never Bury Them in Scroll
Every content page has exactly one primary action. It lives in the top-right of the page header, visible without scrolling. Secondary actions sit to its left as ghost or outline buttons. On long-scroll pages, the primary action becomes sticky (mobile) or stays in the always-visible header (desktop). Floating action buttons are reserved for content creation in mobile-first products — never as a generic catch-all.
Incorrect (primary action below the fold, ghost variant indistinguishable from secondary):
export default function Project() {
return (
<main className="p-6">
<h1>Project: Atlas</h1>
<section>{/* ... lots of content ... */}</section>
<section>{/* ... lots of content ... */}</section>
<div className="mt-12 flex gap-2">
<button className="border px-3 py-1.5">Archive</button>
<button className="border px-3 py-1.5">Share</button>
<button className="border px-3 py-1.5">Publish</button>
</div>
</main>
)
}Correct (primary action in header, clear hierarchy, sticky on mobile):
export default function Project() {
return (
<main>
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-background/95 px-6 py-3 backdrop-blur">
<div>
<h1 className="text-xl font-semibold">Project: Atlas</h1>
<p className="text-sm text-muted-foreground">Edited 2 min ago</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm">Share</Button>
<Button variant="outline" size="sm">Archive</Button>
<Button size="sm">Publish</Button>{/* primary — filled, rightmost */}
</div>
</header>
<section className="p-6">{/* ... */}</section>
</main>
)
}Rule:
- One primary action per page — filled variant, rightmost position
- Maximum 3 secondary actions visible; overflow into a
…dropdown - Header has
sticky top-0andbackdrop-blurso actions remain reachable during scroll - Destructive actions (Delete, Reset) are never primary; place them in an overflow menu or a separate "Danger zone" section
- Floating action buttons (FAB) only for create-content flows on mobile (
< 768 px)
Reference: F-Shaped Pattern of Reading — Nielsen Norman Group
Use Top Nav or Sidebar for Primary Navigation; Never Hamburger-Only on Desktop
Top-level sections must be visible at viewports ≥ 768 px. Use a top nav for 3-7 sections and a sidebar when there are 5+ sections or deep nesting. A hamburger menu may collapse the same navigation on mobile (< 768 px) — never on desktop. Each top-level destination is a noun, not a verb (sections, not actions).
Incorrect (hamburger-only on desktop, actions mixed in):
// app/(marketing)/layout.tsx
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<header className="flex items-center justify-between p-4">
<Logo />
<button onClick={() => setOpen(true)} aria-label="Menu">
<Menu />
</button>
{/* Desktop also has to open a drawer to see sections — kills discovery */}
</header>
{children}
</>
)
}Correct (visible top nav at desktop, drawer at mobile, no actions in nav):
// app/(marketing)/layout.tsx
import Link from 'next/link'
const sections = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/projects', label: 'Projects' },
{ href: '/team', label: 'Team' },
{ href: '/billing', label: 'Billing' },
]
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<header className="flex items-center gap-8 px-6 h-14 border-b">
<Logo />
<nav aria-label="Primary" className="hidden md:flex gap-6">
{sections.map((s) => (
<Link key={s.href} href={s.href} className="text-sm hover:text-foreground/80">
{s.label}
</Link>
))}
</nav>
<MobileNavDrawer sections={sections} className="md:hidden ml-auto" />
</header>
{children}
</>
)
}Rule:
- Maximum 7 top-level sections; if you have more, switch to a sidebar
- Each section is a destination (noun), never an action — "New Project" belongs in the page, not the nav
aria-label="Primary"on the nav element so screen-reader users hear the navigation landmark- Use
next/link, not<a href>, for internal navigation — App Router prefetching kicks in - Mobile drawer is allowed at
< 768 pxonly
Reference: Hamburger menus and hidden navigation hurt UX metrics — Nielsen Norman Group
Use Server Actions With Progressive Enhancement; Never Disable Submit While Typing
Forms use Server Actions ('use server') wired into the native <form action={…}> API so they submit even before JavaScript hydrates. Validation happens on the server (Zod is the standard), errors render inline next to the field via useActionState. Never disable the submit button while the user is typing — let them attempt submission and show errors after. Use aria-invalid and aria-describedby so screen readers announce errors.
Incorrect (submit disabled while invalid; client-only validation; useState everywhere):
'use client'
function SignupForm() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const valid = email.includes('@') && password.length >= 8
return (
<form
onSubmit={async (e) => {
e.preventDefault()
await fetch('/api/signup', { method: 'POST', body: JSON.stringify({ email, password }) })
}}
>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit" disabled={!valid}>Sign up</button>
{/* Disabled until valid → user can't see what's wrong; no SR announcement */}
</form>
)
}Correct (Server Action + Zod + useActionState + inline errors):
// app/signup/actions.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
const schema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export type SignupState = {
fieldErrors?: { email?: string[]; password?: string[] }
formError?: string
}
export async function signupAction(_prev: SignupState, formData: FormData): Promise<SignupState> {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { fieldErrors: parsed.error.flatten().fieldErrors }
try {
await createUser(parsed.data)
} catch (e) {
if (e instanceof EmailInUseError) return { fieldErrors: { email: ['That email is already in use'] } }
return { formError: 'Something went wrong. Please try again.' }
}
redirect('/dashboard')
}
// app/signup/page.tsx — Server Component renders the form; Client Component owns useActionState
import { SignupForm } from './signup-form'
export default function SignupPage() {
return <SignupForm />
}
// app/signup/signup-form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { signupAction, type SignupState } from './actions'
const initial: SignupState = {}
function Field({ id, label, type = 'text', autoComplete, errors }: {
id: string; label: string; type?: string; autoComplete?: string; errors?: string[]
}) {
const errorId = `${id}-error`
return (
<div className="space-y-1">
<label htmlFor={id} className="text-sm font-medium">{label}</label>
<input
id={id}
name={id}
type={type}
autoComplete={autoComplete}
aria-invalid={!!errors?.length}
aria-describedby={errors?.length ? errorId : undefined}
className={cn(
'w-full rounded-md border px-3 h-11',
errors?.length && 'border-destructive'
)}
/>
{errors?.map((msg) => (
<p key={msg} id={errorId} role="alert" className="text-sm text-destructive">{msg}</p>
))}
</div>
)
}
function Submit() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending} className="w-full">
{pending && <Loader2 className="mr-2 size-4 animate-spin" />}
{pending ? 'Creating account…' : 'Create account'}
</Button>
)
}
export function SignupForm() {
const [state, action] = useActionState(signupAction, initial)
return (
<form action={action} className="space-y-4">
{state.formError && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{state.formError}
</p>
)}
<Field id="email" label="Email" type="email" autoComplete="email" errors={state.fieldErrors?.email} />
<Field id="password" label="Password" type="password" autoComplete="new-password" errors={state.fieldErrors?.password} />
<Submit />
</form>
)
}Rule:
- Use
<form action={serverAction}>— works without JS via progressive enhancement - Validate on the server with Zod (or equivalent); return structured errors in the action's return value
- Submit button is never disabled until valid — only disabled during submission (
useFormStatus().pending) - Each error gets
role="alert",aria-invalidon the input, andaria-describedbylinking input to message - Always set
autoCompletecorrectly (email,new-password,current-password,one-time-code) — password managers depend on it
Reference: Form best practices — Baymard Institute · Server Actions — Next.js 16
Destructive Actions Require a Typed Confirmation OR an Undo Window
Destructive actions split into two categories with different patterns:
- Recoverable (delete a comment, archive a project, leave a channel): single-click action + toast with Undo (≥ 6 s) + soft-delete on the server
- Irreversible (delete a workspace, transfer ownership, drop a database): explicit typed confirmation that includes the entity's name
Never use a generic confirm("Are you sure?") browser dialog — it's accessibility-hostile, theme-broken, and unstyled.
Incorrect (browser confirm; no typed confirmation for irreversible action; no undo for recoverable):
function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
return (
<button
onClick={() => {
if (confirm('Are you sure?')) deleteWorkspace(workspace.id) // gone forever, no recovery
}}
>
Delete workspace
</button>
)
}Correct (typed confirmation for irreversible; undo for recoverable):
// IRREVERSIBLE — typed confirmation dialog
'use client'
import { useState } from 'react'
export function DeleteWorkspaceDialog({ workspace }: { workspace: Workspace }) {
const [typed, setTyped] = useState('')
const matches = typed === workspace.name
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button variant="destructive">Delete workspace</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content className="fixed left-1/2 top-1/2 max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6">
<Dialog.Title className="text-lg font-semibold text-destructive">
Delete "{workspace.name}"
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
This will permanently delete the workspace and all {workspace.projectCount} projects.
This action cannot be undone.
</Dialog.Description>
<label className="mt-4 block">
<span className="text-sm font-medium">
Type <code className="rounded bg-muted px-1.5 py-0.5">{workspace.name}</code> to confirm
</span>
<input
value={typed}
onChange={(e) => setTyped(e.target.value)}
className="mt-1 w-full rounded-md border px-3 h-11"
autoComplete="off"
autoCapitalize="off"
aria-required
/>
</label>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close asChild><Button variant="ghost">Cancel</Button></Dialog.Close>
<form action={deleteWorkspaceAction.bind(null, workspace.id)}>
<Button type="submit" variant="destructive" disabled={!matches}>
Delete workspace
</Button>
</form>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
// RECOVERABLE — single click + undo toast, soft-delete on the server
async function onDeleteComment(id: string) {
await deleteCommentAction(id) // sets deletedAt; cron sweeps after 24h
toast.success('Comment deleted', {
action: { label: 'Undo', onClick: () => restoreCommentAction(id) },
duration: 6000,
})
}Rule:
- Irreversible action → typed confirmation including the entity name; submit disabled until match
- Recoverable action → single click + toast-with-Undo (≥ 6 s) + soft-delete on the server (
deletedAt) - Destructive primary buttons use the
destructivevariant (red token, not raw red) - Never use
window.confirm— always a real Radix Dialog - Pair the confirmation Title with the entity name so users have a "this is the thing I want to delete" moment
Reference: Confirmation Dialogs — Apple HIG · GitHub's typed-confirmation pattern
Choose Dialog / Popover / Full-Page by Content Weight; Never Stack Modals
Pick the lightest container that fits the task:
- Popover — anchored to a trigger; ≤ 5 quick choices or compact form (date picker, share menu)
- Dialog (modal) — single focused task; ≤ 7 fields or one decision; centered on desktop, sheet on mobile
- Sheet (slide-over) — secondary task that benefits from staying visually connected to the page (filters, details panel)
- Full page — multi-step flows, anything > 7 fields, or content that must be deep-linkable (shareable URL)
Never open a dialog from inside another dialog. If a dialog needs more depth, switch the entire content of the existing dialog, or escalate to a full page.
Incorrect (using a dialog for a 15-field form; stacking dialogs):
function CreateInvoice() {
return (
<Dialog.Root>
<Dialog.Content className="w-[90vw] max-w-3xl">
{/* 15 fields, sub-forms, file pickers, customer selector that opens another dialog */}
<Dialog.Root>
<Dialog.Content>Customer picker — stacked modal</Dialog.Content>
</Dialog.Root>
</Dialog.Content>
</Dialog.Root>
)
}Correct (each modality matches its weight; use intercepting routes for "open as modal"):
// 1. POPOVER — compact share menu
<Popover.Root>
<Popover.Trigger asChild><Button size="sm">Share</Button></Popover.Trigger>
<Popover.Content className="w-64 rounded-md border bg-popover p-3 shadow-md">
<CopyLinkButton url={shareUrl} />
<EmailShareButton url={shareUrl} />
</Popover.Content>
</Popover.Root>
// 2. DIALOG — one focused task with ≤ 7 fields
<Dialog.Root>
<Dialog.Trigger asChild><Button>Edit profile</Button></Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/40" />
<Dialog.Content className="fixed left-1/2 top-1/2 max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg bg-background p-6">
<Dialog.Title>Edit profile</Dialog.Title>
<ProfileForm /> {/* 3-4 fields */}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
// 3. SHEET — filters panel, stays connected to the list
<Sheet.Root>
<Sheet.Trigger asChild><Button variant="outline"><Filter className="mr-2 size-4" />Filters</Button></Sheet.Trigger>
<Sheet.Content side="right" className="w-full sm:max-w-md">
<FiltersForm />
</Sheet.Content>
</Sheet.Root>
// 4. FULL PAGE via intercepting routes — "open as modal" but with a real URL
// app/projects/@modal/(...)new/page.tsx
import { Dialog } from '@/components/ui/dialog'
import { NewProjectForm } from './new-project-form'
export default function NewProjectModal() {
// Intercepted from /projects/new — same content as the full page, presented as a dialog
return (
<Dialog defaultOpen>
<NewProjectForm />
</Dialog>
)
}
// app/projects/new/page.tsx — the same content as a full page when deep-linked
export default function NewProject() {
return <NewProjectForm />
}Rule:
- Choose modality by weight: popover < dialog < sheet < full page
- Maximum nesting depth: one. Never open a dialog from inside another dialog
- Anything > 7 fields, multi-step, or deep-linkable is a full page (or an intercepting route)
- Mobile: dialogs become bottom sheets (Radix Sheet
side="bottom"or shadcn/ui Drawer) - Provide a real URL via intercepting routes (
(..)slug) when the modal content deserves deep-linking
Reference: Modality — Apple HIG · Intercepting routes — Next.js
Onboarding Never Exceeds 3 Screens and Is Always Skippable
Onboarding is a tax — keep it cheap. Show at most 3 screens, each communicating one concept. Provide a visible Skip on every screen (not buried in a corner). The first action after onboarding must be obvious. For optional permissions and personalization, prefer in-context prompts later — onboarding is for understanding what the app does, not for collecting setup data.
Incorrect (5-screen forced onboarding, collects data upfront, no Skip):
'use client'
function ForcedOnboarding() {
const [step, setStep] = useState(0)
const screens = [
<Welcome />,
<AccountForm />, // gathers data upfront
<NotificationPermission />, // out of context
<FeatureTourA />,
<FeatureTourB />,
]
return (
<div className="fixed inset-0 bg-background p-8">
{screens[step]}
<Button onClick={() => setStep(step + 1)}>Next</Button>
{/* No Skip. Required to complete to use the app. */}
</div>
)
}Correct (3 concise screens, always-visible Skip, defer personalization):
// app/(onboarding)/welcome/page.tsx
import { redirect } from 'next/navigation'
import { completeOnboardingAction } from './actions'
const SCREENS = [
{
title: 'Capture ideas as they happen',
body: 'Atlas turns rough notes into structured projects you can act on.',
image: '/onboarding/capture.svg',
},
{
title: 'Collaborate without meetings',
body: 'Invite teammates to comment, edit, and decide — async.',
image: '/onboarding/collab.svg',
},
{
title: 'Start with a template',
body: 'Pick one to skip the blank page.',
image: '/onboarding/templates.svg',
},
]
export default async function Welcome({ searchParams }: { searchParams: Promise<{ step?: string }> }) {
const { step = '0' } = await searchParams
const i = Number(step)
const screen = SCREENS[i]
if (!screen) return redirect('/dashboard')
return (
<div className="mx-auto flex min-h-svh max-w-md flex-col items-center justify-center p-6 text-center">
<img src={screen.image} alt="" className="size-32" />
<h1 className="mt-6 text-2xl font-semibold">{screen.title}</h1>
<p className="mt-2 text-muted-foreground">{screen.body}</p>
<div className="mt-6 flex items-center gap-2" aria-label="Progress">
{SCREENS.map((_, idx) => (
<span
key={idx}
aria-current={idx === i ? 'step' : undefined}
className={cn('size-1.5 rounded-full', idx === i ? 'bg-primary' : 'bg-muted')}
/>
))}
</div>
<div className="mt-8 flex w-full justify-between">
<form action={completeOnboardingAction}>
<Button variant="ghost" type="submit">Skip</Button>
</form>
<Button asChild>
<Link href={`?step=${i + 1}`}>{i === SCREENS.length - 1 ? 'Get started' : 'Next'}</Link>
</Button>
</div>
</div>
)
}Rule:
- Maximum 3 screens; each screen presents one concept (heading + 1 sentence + visual)
- Skip is visible on every screen, not hidden in a corner — text-button-ghost, bottom-left
- Don't collect data during onboarding — defer to in-context prompts (see ux-permissions)
- Use URL-based steps (
?step=N) so users can back-button and refresh without losing place - After onboarding completes, route the user to the place where the primary CTA will be — not back to a blank dashboard
Reference: Onboarding research — NN/g
Request Browser Permissions in Context, Not on Page Load
Never trigger the browser's native permission prompt as the user arrives. Show a custom "soft" prompt first — explain why you need the permission and what value the user gets — and only trigger the native prompt after they tap "Allow" in your UI. This pattern (the "double-prompt") protects the user from accidentally denying forever (browsers remember "Block" decisions but not custom prompts), and it dramatically increases grant rates.
Incorrect (cold native prompt on page load):
'use client'
import { useEffect } from 'react'
function HomePage() {
useEffect(() => {
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission() // browser modal pops the moment the page loads
}
navigator.geolocation.getCurrentPosition(handleLocation) // same: cold prompt
}, [])
return <Home />
}Correct (soft prompt → native prompt only after user opts in):
// components/notifications-prompt.tsx
'use client'
import { useState } from 'react'
import { Bell, X } from 'lucide-react'
export function NotificationsPrompt() {
const [dismissed, setDismissed] = useState(false)
if (
dismissed ||
typeof window === 'undefined' ||
!('Notification' in window) ||
Notification.permission !== 'default'
) {
return null
}
async function onAllow() {
const result = await Notification.requestPermission()
if (result === 'granted') await subscribeToPush()
setDismissed(true)
}
return (
<aside
role="dialog"
aria-labelledby="notif-prompt-title"
className="fixed bottom-4 right-4 max-w-sm rounded-lg border bg-background p-4 shadow-lg"
>
<div className="flex items-start gap-3">
<Bell className="size-5 text-primary" aria-hidden="true" />
<div className="flex-1">
<h3 id="notif-prompt-title" className="font-medium">Get a ping when your build finishes</h3>
<p className="mt-1 text-sm text-muted-foreground">
We'll only notify you about builds you started. You can turn this off in Settings.
</p>
<div className="mt-3 flex gap-2">
<Button size="sm" onClick={onAllow}>Turn on notifications</Button>
<Button size="sm" variant="ghost" onClick={() => setDismissed(true)}>Not now</Button>
</div>
</div>
<button onClick={() => setDismissed(true)} aria-label="Dismiss" className="size-8 inline-flex items-center justify-center">
<X className="size-4" />
</button>
</div>
</aside>
)
}Trigger only when contextually valuable:
// Inline geolocation — only ask when the user clicks "Use my location"
function StoreLocator() {
return (
<div className="space-y-2">
<input placeholder="Search by ZIP" />
<button
onClick={async () => {
const pos = await new Promise<GeolocationPosition>((res, rej) =>
navigator.geolocation.getCurrentPosition(res, rej)
)
setStores(await findNearby(pos.coords))
}}
>
<MapPin className="mr-2 size-4" /> Use my location
</button>
</div>
)
}Rule:
- Never trigger a native permission prompt on page load or inside a
useEffectthat runs unconditionally - Show a custom soft prompt first; trigger the native prompt only after the user taps Allow in your UI
- Explain why + what the user gets + what data you store — three sentences max
- "Not now" must work — store dismissal so you don't re-prompt for ≥ 7 days
- Provide an in-app Settings switch so the user can re-enable later
Reference: Notification permission UX — Chrome team
Settings Are Autosaved on Change; Never Gated Behind a Save Button
Settings (preferences, profile fields, integrations) save automatically when the user changes them. Use useOptimistic for instant UI feedback and confirm with a discreet toast. The Save button pattern is reserved for: (1) settings that affect billing or external systems (a confirm step is warranted), (2) wizards with multi-step input, (3) settings that must be applied atomically. Group settings by user intent ("Notifications", "Privacy"), not by data type.
Incorrect (one giant Save button at the bottom; alphabetical grouping; loss on navigate):
'use client'
function SettingsPage() {
const [draft, setDraft] = useState(loadedSettings)
const [dirty, setDirty] = useState(false)
return (
<form>
<h2>A</h2><Toggle ... onChange={() => setDirty(true)} />
<h2>B</h2><Toggle ... onChange={() => setDirty(true)} />
...
<Button onClick={() => saveAll(draft)}>Save changes</Button>
{/* If the user navigates away → all changes lost, no warning */}
</form>
)
}Correct (autosave on change, optimistic update, grouped by intent, scoped Server Actions):
// app/settings/actions.ts
'use server'
export async function updateSettingAction(key: string, value: unknown) {
await db.userSetting.upsert({
where: { userId_key: { userId: getUserId(), key } },
update: { value },
create: { userId: getUserId(), key, value },
})
revalidateTag('user-settings')
return { ok: true }
}
// app/settings/notifications-section.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { updateSettingAction } from './actions'
export function NotificationsSection({ initial }: { initial: NotificationSettings }) {
const [optimistic, setOptimistic] = useOptimistic(initial)
const [, startTransition] = useTransition()
function update<K extends keyof NotificationSettings>(key: K, value: NotificationSettings[K]) {
startTransition(async () => {
setOptimistic({ ...optimistic, [key]: value })
const result = await updateSettingAction(key, value)
if (!result.ok) toast.error("Couldn't save — we'll retry")
})
}
return (
<section aria-labelledby="notifs-heading" className="space-y-4">
<h2 id="notifs-heading" className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">
Decide when we should ping you. Changes save automatically.
</p>
<Row label="Build finished" description="Notify me when a build I started completes">
<Switch
checked={optimistic.buildFinished}
onCheckedChange={(v) => update('buildFinished', v)}
/>
</Row>
<Row label="Mentions" description="When someone @-mentions me">
<Switch
checked={optimistic.mentions}
onCheckedChange={(v) => update('mentions', v)}
/>
</Row>
</section>
)
}
function Row({ label, description, children }: { label: string; description: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 py-2">
<div>
<p className="font-medium">{label}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</div>
)
}When a Save button IS appropriate:
// Multi-step wizard or billing-affecting change — collect everything, validate atomically
'use client'
function BillingPlanForm() {
const [state, action, pending] = useActionState(changePlanAction, {})
return (
<form action={action} className="space-y-4">
<fieldset>...plan options...</fieldset>
<p className="text-sm text-muted-foreground">
Your card will be charged the prorated difference today.
</p>
<Button type="submit" disabled={pending}>
{pending ? 'Updating plan…' : 'Confirm plan change'}
</Button>
</form>
)
}Rule:
- Default: autosave on change with
useOptimistic+ toast confirmation - Group settings by user intent ("Notifications", "Privacy", "Workspace") — never alphabetically
- Each setting has a label and a short why-it-matters description
- Save buttons only for: billing, multi-step wizards, atomic-apply settings
- Settings page is a Server Component; sections are Client Components scoped to their own state
Reference: Settings UX — NN/g
Prefer Undo Over Confirmation for Everyday Actions
For frequent, recoverable actions (archive, delete a comment, mark read, dismiss notification, leave channel), let the user act in one click and offer Undo via a toast that persists for at least 6 seconds. The server soft-deletes (deletedAt column) and a scheduled job sweeps after 24 hours. Reserve confirmation dialogs for irreversible actions (see ux-destructive-confirmation) and for actions that affect other people.
Incorrect (every action prompts a confirmation, no undo path, hard delete):
function CommentRow({ comment }: { comment: Comment }) {
return (
<li>
<p>{comment.body}</p>
<button
onClick={() => {
if (confirm('Delete this comment?')) {
deleteCommentAction(comment.id) // hard delete; gone forever
}
}}
>
Delete
</button>
</li>
)
}Correct (one-click action, optimistic update, undo toast, soft-delete on server):
'use client'
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { deleteCommentAction, restoreCommentAction } from './actions'
export function CommentRow({ comment }: { comment: Comment }) {
const [optimisticDeleted, setOptimisticDeleted] = useOptimistic(false)
const [, startTransition] = useTransition()
function onDelete() {
startTransition(async () => {
setOptimisticDeleted(true)
const result = await deleteCommentAction(comment.id)
if (!result.ok) {
setOptimisticDeleted(false)
toast.error(`Couldn't delete comment`)
return
}
toast.success('Comment deleted', {
action: { label: 'Undo', onClick: () => restoreCommentAction(comment.id) },
duration: 6000,
})
})
}
if (optimisticDeleted) return null
return (
<li className="group flex gap-2 p-3">
<p className="flex-1">{comment.body}</p>
<Button
variant="ghost"
size="icon"
onClick={onDelete}
aria-label="Delete comment"
className="opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 size-11"
>
<Trash2 className="size-4" />
</Button>
</li>
)
}Server side: soft-delete + sweep job:
// app/comments/actions.ts
'use server'
export async function deleteCommentAction(id: string) {
await db.comment.update({ where: { id }, data: { deletedAt: new Date() } })
revalidateTag(`comments-${id}`)
return { ok: true }
}
export async function restoreCommentAction(id: string) {
await db.comment.update({ where: { id }, data: { deletedAt: null } })
revalidateTag(`comments-${id}`)
return { ok: true }
}
// scheduled job, e.g. cron / Inngest
export async function sweepDeletedComments() {
await db.comment.deleteMany({
where: { deletedAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } },
})
}Rule:
- Recoverable everyday actions: one-click + toast-with-Undo (≥ 6 s) + soft-delete on the server
- Soft-delete columns (
deletedAt) sweep after 24 hours via a scheduled job - Reserve confirmation dialogs for: irreversible actions, actions affecting other people, actions that move significant money
- Pair Undo with optimistic UI — the row disappears immediately, restored if the user clicks Undo
- Server Action returns
{ ok, error }— never throw; client decides how to react
Reference: Aza Raskin: Never use a warning when you mean undo
Use CSS Custom Properties + dark: Variant; Never Hardcode text-black / bg-white
Define semantic color tokens (--background, --foreground, --muted-foreground, --border, --ring, --primary, --destructive) in app/globals.css under @theme and switch them in a .dark selector. Reference tokens via Tailwind's color utilities (bg-background, text-foreground). Never write text-black, bg-white, text-gray-500, or any literal grayscale class — those don't theme.
Incorrect (hardcoded grayscale; no dark mode support; arbitrary opacities):
function Card() {
return (
<div className="bg-white border-gray-200 text-black">
<h2 className="text-gray-900">Title</h2>
<p className="text-gray-500">Description</p>
{/* Glaring white in dark mode; near-invisible text-gray-500 contrast */}
</div>
)
}Correct (semantic tokens + `dark:` switched once, components use the tokens):
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-background: oklch(1 0 0);
--color-foreground: oklch(0.15 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.45 0 0);
--color-border: oklch(0.92 0 0);
--color-ring: oklch(0.55 0.18 250);
--color-primary: oklch(0.55 0.18 250);
--color-primary-foreground: oklch(0.98 0 0);
--color-destructive: oklch(0.55 0.22 25);
--color-destructive-foreground: oklch(0.98 0 0);
}
.dark {
--color-background: oklch(0.13 0 0);
--color-foreground: oklch(0.96 0 0);
--color-muted: oklch(0.18 0 0);
--color-muted-foreground: oklch(0.65 0 0);
--color-border: oklch(0.25 0 0);
--color-ring: oklch(0.65 0.18 250);
--color-primary: oklch(0.65 0.18 250);
--color-primary-foreground: oklch(0.13 0 0);
--color-destructive: oklch(0.65 0.22 25);
}// Components reference tokens — never raw grayscale
function Card({ title, description }: { title: string; description: string }) {
return (
<article className="rounded-lg border border-border bg-background p-6 text-foreground">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</article>
)
}
// Theme toggle — respects system preference by default, user override via class
'use client'
import { useTheme } from 'next-themes'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
<Button
variant="ghost"
size="icon"
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
<Sun className="size-4 dark:hidden" aria-hidden="true" />
<Moon className="size-4 hidden dark:block" aria-hidden="true" />
</Button>
)
}
// Root layout — class strategy + system default
import { ThemeProvider } from 'next-themes'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}Rule:
- Define semantic tokens once in
@theme; switch them in a.darkselector - Components reference tokens (
bg-background,text-foreground,border-border) — never raw grayscale (bg-white,text-gray-500) - Default to system theme; allow override via
next-themesattribute="class" - Verify both themes — color contrast (4.5:1) must pass in both
- Use
suppressHydrationWarningon<html>to avoid the brief flash from theme-class injection
Reference: next-themes docs · Tailwind v4 theming
Related skills
FAQ
What does web-rules do?
web-rules is a Claude Code skill for ai & agent building. It helps developers move faster with AI-assisted coding.
When should I use web-rules?
When you need to helps with ai & agent building tasks during ai-assisted development, or when web-rules is a claude code skill for ai & agent building. it helps developers move faster with ai-assisted coding.
What are the main capabilities?
web-rules; AI & Agent Building; AI-coding skill.