PR Guidelines
Code review guidelines to speed up the team review process.
Code Duplication
# Code Duplication
### What
Extract repeated UI patterns with identical Tailwind styling into reusable components instead of copy-pasting them.
### Why
Duplicated styled code blocks become maintenance nightmares and create inconsistency vectors when updates are applied to only some instances.
### Good
```jsx
// Button.jsx
export const Button = ({ children }) => (
<button className="px-4 py-2 bg-blue-500 text-white rounded">
{children}
</button>
);
// Usage
<Button>Save</Button>
<Button>Delete</Button>
```
### Bad
```jsx
// Repeated everywhere
<button className="px-4 py-2 bg-blue-500 text-white rounded">Save</button>
<button className="px-4 py-2 bg-blue-500 text-white rounded">Delete</button>
<button className="px-4 py-2 bg-blue-500 text-white rounded">Cancel</button>
```
Context Over Prop Drilling
# Context Over Prop Drilling
### What
Use React Context to share state and callbacks across deeply nested components instead of passing props through multiple intermediate layers. When callbacks involve logic that primarily uses context values, define them inside the context provider and expose them through the context.
### Why
Prop drilling creates tight coupling between components, makes refactoring painful, and clutters component signatures with props that are merely "passed through." Context centralizes shared state and logic, making components cleaner and the data flow more maintainable.
### Good
```jsx
// AuthContext.jsx
const AuthContext = createContext(null)
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(false)
const login = async (credentials) => {
setIsLoading(true)
const userData = await authApi.login(credentials)
setUser(userData)
setIsLoading(false)
}
const logout = () => {
authApi.logout()
setUser(null)
}
return (
<AuthContext.Provider value={{ user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
// DeepNestedComponent.jsx
const DeepNestedComponent = () => {
const { user, logout } = useAuth()
return <button onClick={logout}>Logout {user.name}</button>
}
```
### Bad
```jsx
// Prop drilling through multiple layers
const App = () => {
const [user, setUser] = useState(null)
const logout = () => {
authApi.logout()
setUser(null)
}
return <Layout user={user} logout={logout} />
}
const Layout = ({ user, logout }) => <Sidebar user={user} logout={logout} />
const Sidebar = ({ user, logout }) => <UserMenu user={user} logout={logout} />
const UserMenu = ({ user, logout }) => (
<button onClick={logout}>Logout {user.name}</button>
)
```
Data Slot Approach
We wrote about why we do prefer the data-slot approach over multiple class props in the 01 - The Slot Approach log.
# Data Slot Approach
### What
Use `data-slot` attributes to name inner elements instead of exposing multiple `className` props like `titleClassName`, `descriptionClassName`, etc. One `className` prop for the root element is fine.
### Why
Multiple className props clutter component signatures and make the API messier as the component grows. The `data-slot` approach leverages CSS-first styling, keeping props clean while still allowing parent components to style inner elements via the `**:data-[slot=name]` selector.
### Good
```jsx
// Component with data-slot attributes
export function CardLink({ href, title, description, className }) {
return (
<Card className={cn('hover:bg-accent/50', className)} asChild>
<Link href={href}>
<div data-slot="card-title">
{title}
<ArrowRightIcon />
</div>
<p data-slot="card-description">{description}</p>
</Link>
</Card>
)
}
// Usage - styling inner elements via CSS selector
<CardLink className="**:data-[slot=card-description]:opacity-50" />
```
### Bad
```jsx
// Multiple className props - clutters the API
export function CardLink({
href,
title,
description,
className,
titleClassName,
descriptionClassName,
}) {
return (
<Card className={cn('hover:bg-accent/50', className)} asChild>
<Link href={href}>
<div className={titleClassName}>
{title}
<ArrowRightIcon />
</div>
<p className={descriptionClassName}>{description}</p>
</Link>
</Card>
)
}
// Usage
<CardLink titleClassName="font-bold" descriptionClassName="opacity-50" />
```
JS Media Queries
# Avoid JS-Based Media Queries for Initial Render
### What
Never use JavaScript-based media queries (`window.matchMedia`, `useMediaQuery` hooks, or `innerWidth` checks) to determine layout or visibility of elements on initial render. Only use them in components where JavaScript has already loaded and React has fully hydrated.
### Why
During Server-Side Rendering (SSR), the server has no knowledge of the client's viewport size. JavaScript media queries must default to a fallback value (often `false` or a desktop-first assumption), which creates several problems:
1. **Hydration mismatch**: React expects the server-rendered HTML to match the initial client render. When the client hydrates with a different viewport result, React detects a mismatch, potentially causing rendering errors or forcing a full re-render.
2. **Layout shift (CLS)**: Users see content "jump" as elements resize, reposition, or toggle visibility after hydration. This hurts Core Web Vitals scores and creates a jarring user experience.
3. **Flash of incorrect content (FOUC)**: On slower connections or devices, users may see the wrong layout for several hundred milliseconds before JavaScript executes and corrects it. A mobile user might briefly see a desktop layout, or vice versa.
CSS media queries don't have this problem—they're evaluated by the browser immediately when parsing CSS, before any JavaScript runs, ensuring consistent rendering from the very first paint.
### Good
```tsx
// CSS-based responsive behavior - works correctly with SSR
export function Navigation() {
return (
<nav>
{/* Desktop nav - hidden on mobile via CSS */}
<div className="hidden md:flex gap-4">
<NavLinks />
</div>
{/* Mobile menu button - hidden on desktop via CSS */}
<MobileMenuButton className="flex md:hidden" />
</nav>
)
}
// JS media query is acceptable here because the modal
// only renders after user interaction (click), well after hydration
export function MobileMenu() {
const [isOpen, setIsOpen] = useState(false)
const isMobile = useMediaQuery('(max-width: 768px)')
// This is fine - component only matters after user clicks
if (!isOpen) return null
return <MobileMenuContent variant={isMobile ? 'fullscreen' : 'sidebar'} />
}
```
### Bad
```tsx
// JS-based responsive behavior - causes layout shift and hydration issues
export function Navigation() {
const isMobile = useMediaQuery('(max-width: 768px)')
// Server renders with isMobile=false (fallback)
// Client hydrates with isMobile=true on mobile devices
// Result: layout shift, hydration mismatch, flash of wrong content
return (
<nav>
{isMobile ? (
<MobileMenuButton />
) : (
<div className="flex gap-4">
<NavLinks />
</div>
)}
</nav>
)
}
```
### When JS Media Queries Are Acceptable
- **After user interaction**: Modals, dropdowns, and tooltips that only render after a click or hover.
- **Client-only components**: Components explicitly lazy-loaded or rendered after hydration (behind `useEffect` or `Suspense`).
- **Non-visual logic**: Analytics, feature flags, or behavior that doesn't affect the rendered layout.
```tsx
// Safe pattern: guard with hydration check for unavoidable cases
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => setIsHydrated(true), [])
const isMobile = useMediaQuery('(max-width: 768px)')
// Only use the JS result after hydration; render both variants via CSS first
const effectiveIsMobile = isHydrated ? isMobile : undefined
```
Kebab-Case File Naming
# Kebab-Case File Naming
### What
Use lowercase kebab-case (`my-component.tsx`) instead of PascalCase (`MyComponent.tsx`) for file and folder names.
### Why
File systems differ in case sensitivity—Linux/macOS are case-sensitive while Windows is case-insensitive. Mixing PascalCase can lead to confusing Git renames, mistaken file references, or broken imports when collaborating across different operating systems. Using all-lowercase kebab-case avoids these cross-platform issues entirely.
### Good
```
components/
button.tsx
card-link.tsx
file-dropzone.tsx
mobile-menu.tsx
hooks/
use-config.ts
use-file-upload.tsx
lib/
compose-refs.ts
external-registries.ts
```
### Bad
```
components/
Button.tsx
CardLink.tsx
FileDropzone.tsx
MobileMenu.tsx
hooks/
UseConfig.ts
useFileUpload.tsx
lib/
ComposeRefs.ts
ExternalRegistries.ts
```
Accessibility Basics
# Accessibility Basics
### What
Follow these core accessibility rules for interactive elements:
- Icon-only buttons need `aria-label`
- Form controls need `<label>` or `aria-label`
- Interactive elements need keyboard handlers (`onKeyDown`/`onKeyUp`)
- Use `<button>` for actions, `<a>`/`<Link>` for navigation (not `<div onClick>`)
- Images need `alt` (or `alt=""` if decorative)
- Decorative icons need `aria-hidden="true"`
### Why
Accessibility isn't optional—it ensures everyone can use your interface, including users with screen readers, keyboard-only navigation, and other assistive technologies. Proper semantic HTML also improves SEO and makes your code more maintainable.
### Good
```tsx
// Icon button with aria-label
<button aria-label="Close dialog" onClick={onClose}>
<XIcon aria-hidden="true" />
</button>
// Proper navigation vs action distinction
<Link href="/settings">Settings</Link>
<button onClick={handleSave}>Save Changes</button>
// Form with proper labeling
<label htmlFor="email">Email</label>
<input id="email" type="email" />
// Or using aria-label for icon inputs
<input aria-label="Search" type="search" />
// Image with alt text
<img src="/avatar.jpg" alt="User profile photo" width={40} height={40} />
// Decorative image
<img src="/pattern.svg" alt="" aria-hidden="true" />
```
### Bad
```tsx
// Icon button without label - screen readers can't identify it
<button onClick={onClose}>
<XIcon />
</button>
// Div with click handler instead of semantic button
<div onClick={handleSave} className="cursor-pointer">
Save Changes
</div>
// Link used for action instead of navigation
<a onClick={handleDelete}>Delete</a>
// Input without label
<input type="email" placeholder="Email" />
// Image without alt text
<img src="/avatar.jpg" />
```
Focus States
# Focus States
### What
- Interactive elements need visible focus: use `focus-visible:ring-*` or equivalent
- Never use `outline-none` / `outline: none` without a focus replacement
- Use `:focus-visible` over `:focus` (avoids focus ring on click)
- Group focus with `:focus-within` for compound controls
### Why
Focus indicators are essential for keyboard navigation. Users who can't use a mouse rely on visible focus to know where they are in the interface. Removing outlines without replacement makes your site unusable for these users. Using `focus-visible` instead of `focus` provides the best of both worlds—keyboard users see the ring, but mouse users don't see it on click.
### Good
```tsx
// Button with proper focus-visible state
<button className="px-4 py-2 rounded bg-blue-500 focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2">
Submit
</button>
// Custom input with focus replacement
<input
className="border rounded px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-primary"
/>
// Compound control with focus-within
<div className="flex border rounded focus-within:ring-2 focus-within:ring-primary">
<input className="flex-1 px-3 py-2 outline-none" />
<button className="px-3 border-l">Search</button>
</div>
// Link with visible focus
<Link
href="/about"
className="text-blue-600 underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-400 rounded"
>
About Us
</Link>
```
### Bad
```tsx
// Removes outline with no replacement - invisible to keyboard users
<button className="outline-none">Submit</button>
// Using focus instead of focus-visible - shows ring on mouse click
<button className="focus:ring-2 focus:ring-blue-400">Submit</button>
// Custom input removes outline without adding focus state
<input className="border rounded px-3 py-2 outline-none" />
```
Hover & Interactive States
# Hover & Interactive States
### What
- Buttons and links need `hover:` states for visual feedback
- Interactive states should increase contrast: hover/active/focus must be more prominent than the rest state
- Provide visual feedback for all user interactions
### Why
Without hover states, users can't tell what's clickable. Interactive elements should respond to user attention—this is a fundamental UX expectation. The state progression (rest → hover → active → focus) should feel natural, with each state being slightly more prominent than the last.
### Good
```tsx
// Button with full state progression
<button
className="
bg-blue-500 text-white px-4 py-2 rounded
hover:bg-blue-600
active:bg-blue-700
focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2
"
>
Submit
</button>
// Link with hover state
<Link
href="/about"
className="text-blue-600 hover:text-blue-800 hover:underline"
>
About Us
</Link>
// Card with hover effect
<div className="border rounded-lg p-4 transition-colors hover:bg-muted/50 hover:border-foreground/20">
<h3>Card Title</h3>
<p>Card content</p>
</div>
// Icon button with hover
<button
aria-label="Settings"
className="p-2 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted"
>
<SettingsIcon aria-hidden="true" />
</button>
// Ghost button variant
<button className="px-4 py-2 rounded hover:bg-accent active:bg-accent/80">
Cancel
</button>
```
### Bad
```tsx
// No hover state - looks static/disabled
<button className="bg-blue-500 text-white px-4 py-2 rounded">
Submit
</button>
// Link without hover feedback
<Link href="/about" className="text-blue-600">
About Us
</Link>
// Hover state that reduces contrast (wrong direction)
<button className="bg-blue-600 hover:bg-blue-400">
Submit
</button>
// Clickable element with no visual feedback
<div onClick={handleClick} className="p-4 cursor-pointer">
Click me
</div>
```
Animation Performance
# Animation Performance
### What
- Animate only `transform` and `opacity` (compositor-friendly properties)
- Never use `transition: all`—list properties explicitly
- Set correct `transform-origin`
- For SVG animations, apply transforms on a `<g>` wrapper with `transform-box: fill-box; transform-origin: center`
- Animations should be interruptible—respond to user input mid-animation
### Why
Animating `transform` and `opacity` runs on the GPU compositor thread, avoiding expensive layout recalculations and repaints. Other properties like `width`, `height`, `top`, `left`, `margin`, or `padding` trigger layout thrashing and cause jank, especially on lower-end devices. Using `transition: all` is wasteful and can cause unexpected transitions on properties you didn't intend to animate.
### Good
```tsx
// Only animating transform and opacity
<div className="transition-transform duration-200 hover:scale-105">
Hover me
</div>
// Explicit property list
<button className="transition-[transform,opacity] duration-150 hover:opacity-80 active:scale-95">
Click me
</button>
// Correct transform-origin for scale
<div className="origin-top-left transition-transform hover:scale-110">
Scales from top-left
</div>
// SVG with proper transform setup
<svg viewBox="0 0 24 24">
<g className="origin-center transition-transform hover:rotate-180" style={{ transformBox: 'fill-box' }}>
<path d="..." />
</g>
</svg>
```
### Bad
```tsx
// Animating layout properties - causes layout thrashing
<div className="transition-all duration-200 hover:w-64 hover:h-64">
Laggy resize
</div>
// transition-all is wasteful and unpredictable
<button className="transition-all duration-200 hover:bg-blue-600">
Submit
</button>
// Animating top/left instead of transform
<div className="absolute transition-all duration-200 hover:top-4 hover:left-4">
Jittery movement
</div>
// Missing transform-origin - unexpected pivot point
<div className="transition-transform hover:scale-150">
Where does this scale from?
</div>
```
Typography Conventions
# Typography Conventions
### What
- Use `…` (ellipsis character) not `...` (three periods)
- Use curly quotes `"` `"` not straight quotes `"`
- Use non-breaking spaces for units and keyboard shortcuts: `10 MB`, `⌘ K`
- Loading states end with `…`: `"Loading…"`, `"Saving…"`
- Use `font-variant-numeric: tabular-nums` for number columns and comparisons
- Use `text-wrap: balance` or `text-pretty` on headings to prevent widows
### Why
Professional typography improves readability and perceived quality. The ellipsis character (`…`) is a single glyph designed for this purpose. Curly quotes are typographically correct. Non-breaking spaces prevent awkward line breaks that separate units from numbers or split keyboard shortcuts. Tabular numbers ensure columns align properly. Balanced text wrapping prevents single words on their own line (widows).
### Good
```tsx
// Proper ellipsis
<span>Loading…</span>
<p className="truncate">This text will be truncated…</p>
// Curly quotes
<blockquote>"Design is not just what it looks like, design is how it works."</blockquote>
// Non-breaking spaces (use or actual non-breaking space character)
<span>10 MB</span>
<kbd>⌘ K</kbd>
<span>New York</span>
// Tabular numbers for alignment
<td className="tabular-nums">1,234.56</td>
<td className="tabular-nums">12.00</td>
// Balanced heading
<h1 className="text-wrap-balance">
Building the Future of Design Systems
</h1>
// Or using text-pretty for paragraphs
<p className="text-pretty">
A longer paragraph that should avoid orphans at the end.
</p>
```
### Bad
```tsx
// Three periods instead of ellipsis
<span>Loading...</span>
// Straight quotes
<blockquote>"Design is not just what it looks like"</blockquote>
// Breaking space between number and unit
<span>10 MB</span> // Can break as "10" on one line, "MB" on next
// Proportional numbers in a table - columns won't align
<td>1,234.56</td>
<td>12.00</td>
// Unbalanced heading - may have single word on last line
<h1>Building the Future of Design Systems</h1>
```
Content Overflow
# Content Overflow
### What
- Text containers must handle long content: use `truncate`, `line-clamp-*`, or `break-words`
- Flex children need `min-w-0` to allow text truncation
- Handle empty states—don't render broken UI for empty strings/arrays
- Anticipate user-generated content of all lengths: short, average, and very long
### Why
Real-world data is unpredictable. User names can be 2 characters or 50. Titles can be a word or a paragraph. Without proper overflow handling, long content breaks layouts, overflows containers, or causes horizontal scrolling. The `min-w-0` rule is particularly important—flex items have `min-width: auto` by default, which prevents them from shrinking below their content width.
### Good
```tsx
// Truncate single line
<p className="truncate">{user.name}</p>
// Clamp to multiple lines
<p className="line-clamp-3">{post.description}</p>
// Break long words (URLs, hashes, etc.)
<code className="break-all">{transactionHash}</code>
// Flex child with min-w-0 for truncation
<div className="flex items-center gap-2">
<Avatar />
<span className="min-w-0 truncate">{user.displayName}</span>
</div>
// Handle empty state
{items.length > 0 ? (
<ItemList items={items} />
) : (
<EmptyState message="No items found" />
)}
// Handle potentially missing/empty string
<h2>{title || 'Untitled'}</h2>
```
### Bad
```tsx
// No overflow handling - breaks on long content
<p>{user.biography}</p>
// Flex without min-w-0 - truncate won't work
<div className="flex items-center gap-2">
<Avatar />
<span className="truncate">{user.displayName}</span>
</div>
// Missing empty state - renders empty container or crashes
{items.map(item => <Item key={item.id} {...item} />)}
// Renders broken UI with empty string
<h2>{title}</h2> // Shows nothing, may break layout
```
Image Optimization
# Image Optimization
### What
- `<img>` elements need explicit `width` and `height` attributes (prevents CLS)
- Below-fold images: use `loading="lazy"`
- Above-fold critical images: use `priority` (Next.js) or `fetchpriority="high"`
### Why
Without explicit dimensions, the browser doesn't know how much space to reserve for an image until it loads. This causes Cumulative Layout Shift (CLS)—content jumps around as images pop in, which hurts Core Web Vitals scores and creates a poor user experience. Lazy loading defers off-screen images, improving initial page load. Priority hints ensure critical images load first.
### Good
```tsx
// Always include width and height
<img
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={600}
/>
// Next.js Image with priority for above-fold
import Image from 'next/image'
<Image
src="/hero.jpg"
alt="Hero banner"
width={1200}
height={600}
priority
/>
// Lazy load below-fold images
<img
src="/gallery-1.jpg"
alt="Gallery image"
width={400}
height={300}
loading="lazy"
/>
// Native img with fetchpriority for critical images
<img
src="/logo.svg"
alt="Company logo"
width={120}
height={40}
fetchpriority="high"
/>
```
### Bad
```tsx
// Missing dimensions - causes layout shift
<img src="/hero.jpg" alt="Hero banner" />
// Missing alt text
<img src="/hero.jpg" width={1200} height={600} />
// All images lazy loaded - hurts LCP for above-fold
<img src="/hero.jpg" alt="Hero" width={1200} height={600} loading="lazy" />
// Only width or height - still causes CLS
<img src="/hero.jpg" alt="Hero" width={1200} />
```
Form Best Practices
# Form Best Practices
### What
- Use correct `type` attributes (`email`, `tel`, `url`, `number`) and `inputmode`
- Never block paste with `onPaste` + `preventDefault`
- Labels must be clickable (`htmlFor` or wrapping the control)
- Disable spellcheck on emails, codes, usernames: `spellCheck={false}`
- Checkboxes/radios: label + control share single hit target (no dead zones)
- Submit button stays enabled until request starts
- Show errors inline next to fields; focus first error on submit
- Use `autocomplete="off"` on non-auth fields to avoid password manager triggers
### Why
Proper input types improve mobile UX by showing the right keyboard (email shows `@`, tel shows number pad). Blocking paste is hostile—users paste passwords from managers, verification codes from messages, etc. Clickable labels increase hit targets and improve accessibility. Pre-disabling submit buttons prevents submission entirely if JavaScript fails.
### Good
```tsx
<form onSubmit={handleSubmit}>
{/* Proper type and autocomplete */}
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
spellCheck={false}
required
/>
{errors.email && <p className="text-red-500 text-sm">{errors.email}</p>}
{/* Phone with tel type and inputmode */}
<label htmlFor="phone">Phone</label>
<input
id="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
/>
{/* Verification code - no autocomplete, no spellcheck */}
<label htmlFor="code">Verification Code</label>
<input
id="code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
spellCheck={false}
pattern="[0-9]*"
/>
{/* Checkbox with clickable label */}
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="terms" />
<span>I agree to the terms</span>
</label>
{/* Submit enabled until loading */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting…' : 'Submit'}
</button>
</form>
```
### Bad
```tsx
<form>
{/* Wrong type - shows regular keyboard on mobile */}
<input type="text" placeholder="Email" />
{/* Blocking paste - hostile to users */}
<input
type="password"
onPaste={(e) => e.preventDefault()}
/>
{/* Label not connected to input */}
<label>Username</label>
<input type="text" />
{/* Spellcheck on email - shows red squiggles */}
<input type="email" />
{/* Checkbox with dead zone between checkbox and text */}
<div className="flex items-center gap-4">
<input type="checkbox" id="terms" />
<label htmlFor="terms">I agree to the terms</label>
</div>
</form>
```
Responsive Hiding
# Responsive Hiding with max-* Variants
### What
Use Tailwind's `max-{breakpoint}` variants with `hidden` to hide elements below specific breakpoints, while keeping the default display value intact (e.g., `flex max-sm:hidden`).
### Why
When you use `hidden sm:flex`, the base state is `hidden`. Consumers might override this with their own classes, not realizing the component is hidden by default. They might add `className="flex"` thinking they're just setting display, but they're actually fighting the responsive behavior.
Using `flex max-sm:hidden` keeps the natural display as the default. The component is visible by default and only hidden on small screens. This makes the API more predictable and prevents accidental overrides.
### Good
```tsx
// Desktop nav - visible by default, hidden on mobile
<nav className="flex max-md:hidden items-center gap-4">
<NavLinks />
</nav>
// Mobile menu button - visible by default, hidden on desktop
<button className="flex md:max-[0px]:hidden items-center">
<MenuIcon />
</button>
// Or more simply for "show only on mobile"
<button className="md:hidden flex items-center">
<MenuIcon />
</button>
// Sidebar - visible on desktop, hidden on mobile
<aside className="w-64 max-lg:hidden">
<SidebarContent />
</aside>
```
### Bad
```tsx
// Base is hidden - consumers might accidentally override
<nav className="hidden md:flex items-center gap-4">
<NavLinks />
</nav>
// If consumer does this, they break the responsive behavior:
<Navigation className="flex" /> // Now it's always visible
// Confusing chain of overrides
<div className="hidden sm:block md:hidden lg:block">
Hard to reason about
</div>
```
### When to Use Each Pattern
- `flex max-md:hidden` → "Show by default, hide on mobile"
- `md:hidden` → "Show only on mobile" (this is fine because base is visible)
- `hidden md:flex` → Avoid this pattern when the component accepts className
Touch & Interaction
# Touch & Interaction
### What
- Use `touch-action: manipulation` to prevent double-tap zoom delay
- Set `-webkit-tap-highlight-color` intentionally
- Use `overscroll-behavior: contain` in modals, drawers, and sheets
- During drag operations: disable text selection, use `inert` on dragged elements
- Use `autoFocus` sparingly—desktop only, single primary input; avoid on mobile
### Why
Mobile browsers add a 300ms delay waiting for double-tap zoom. `touch-action: manipulation` removes this delay for a snappier feel. The default tap highlight can look jarring—set it intentionally or disable it. Without `overscroll-behavior: contain`, scrolling to the end of a modal can accidentally scroll the page behind it. Auto-focusing inputs on mobile can cause the keyboard to pop up unexpectedly, pushing content around.
### Good
```tsx
// Fast tap response on interactive elements
<button className="touch-manipulation">
Tap me
</button>
// Or apply globally in CSS
// button, a, [role="button"] { touch-action: manipulation; }
// Intentional tap highlight (or none)
<button className="[-webkit-tap-highlight-color:transparent]">
No highlight
</button>
// Modal/drawer with contained scroll
<div
role="dialog"
className="fixed inset-0 overflow-auto overscroll-contain"
>
<DialogContent />
</div>
// Sheet that doesn't scroll the page behind
<div className="fixed bottom-0 inset-x-0 max-h-[80vh] overflow-auto overscroll-contain">
<SheetContent />
</div>
// Conditional autoFocus - desktop only
<input
autoFocus={!isMobile}
placeholder="Search"
/>
// Drag operation setup
<div
draggable
onDragStart={() => {
document.body.classList.add('select-none')
}}
onDragEnd={() => {
document.body.classList.remove('select-none')
}}
>
Drag me
</div>
```
### Bad
```tsx
// No touch-action - 300ms delay on tap
<button onClick={handleClick}>
Slow response
</button>
// Default tap highlight can look jarring
<a href="/page">Link with default blue highlight</a>
// Modal without overscroll containment
<div role="dialog" className="fixed inset-0 overflow-auto">
{/* Scrolling past end scrolls the page */}
</div>
// AutoFocus on mobile - keyboard pops up immediately
<input autoFocus placeholder="Search" />
// Text selection during drag is annoying
<div draggable>
Users accidentally select text while dragging
</div>
```