Web UI best practices
Principles for building web interfaces that feel fast, intentional, and respectful of the user's time. Every rule here is a smell test — violating one is fine if you have a reason, violating several means the UI needs work.
Speed
Every interaction completes in under 100ms. If it can't, fake it.
- Optimistic UI updates — show the result before the server confirms
- Debounce inputs, but never debounce perceived response
- Prefetch likely next routes on hover or viewport entry
- Use
will-changeandtransformfor animations, nevertop/left - Measure with
performance.now(), not gut feel
// Optimistic delete — remove from UI immediately, reconcile later
async function handleDelete(id) {
setItems(prev => prev.filter(i => i.id !== id));
try {
await api.delete(`/items/${id}`);
} catch {
setItems(prev => [...prev, originalItem]);
toast("Couldn't delete. Restored.");
}
}
Skeleton loading states
Never show a spinner when you know the shape of what's coming. Render a skeleton that matches the layout, then swap in real content.
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
No product tours
If you need a tour to explain your UI, the UI is wrong. Instead:
- Empty states that teach by doing ("Create your first project")
- Progressive disclosure — show features when they become relevant
- Inline hints that disappear after first use
- Defaults that work without configuration
URLs
Slugs are short, readable, and human-guessable. No UUIDs, no query param soup.
Good: /projects/weather-app
/settings/billing
/docs/api/auth
Bad: /projects/550e8400-e29b-41d4-a716-446655440000
/app?view=settings&tab=billing&subsection=plan
/dashboard#!/module/documents/list?filter=active
- Use slugs derived from user-provided names
- Keep nesting to 3 segments max
- Make URLs copyable and shareable — they are the product's memory
Persistent resumable state
Users leave and come back. Respect that.
- Save draft form state to
localStorageor the server - Restore scroll position on back navigation
- Preserve filter/sort selections across sessions
- URL encodes the current view state — sharing a URL reproduces the view
// Persist form state across sessions
function usePersistentForm(key, defaults) {
const [state, setState] = useState(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : defaults;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}
Color restraint
Not more than 3 colors. One primary, one accent, one for danger/destructive. Everything else is shades of gray.
:root {
--color-primary: #2563eb;
--color-accent: #f59e0b;
--color-danger: #ef4444;
--gray-50: #fafafa;
--gray-100: #f4f4f5;
--gray-200: #e4e4e7;
--gray-400: #a1a1aa;
--gray-600: #52525b;
--gray-900: #18181b;
}
- Use opacity and lightness to create hierarchy, not new hues
- Dark mode is the same 3 colors with inverted grays
- If you reach for a 4th color, you're compensating for weak layout
No visible scrollbars
Hide them unless the user is actively scrolling. Content feels infinite, not trapped.
/* Hide scrollbar across browsers */
.scroll-container {
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
}
.scroll-container::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
Use scroll shadows to hint at overflow without chrome:
.scroll-shadow {
background:
linear-gradient(white 30%, transparent),
linear-gradient(transparent, white 70%) 0 100%,
radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.15), transparent),
radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.15), transparent) 0 100%;
background-repeat: no-repeat;
background-size: 100% 40px, 100% 40px, 100% 12px, 100% 12px;
background-attachment: local, local, scroll, scroll;
}
Navigation depth
All navigation is 3 steps or fewer from anywhere. If the user needs more than 3 clicks to reach a destination, flatten the hierarchy.
- Breadcrumbs for depth, not for navigation
- Global nav always visible, never hidden behind a hamburger on desktop
- Use
Cmd+K/Ctrl+Kas the escape hatch for power users
Command palette (Cmd+K)
Every app with more than one page needs a command palette.
// Minimal Cmd+K listener
useEffect(() => {
function handleKeyDown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setCommandPaletteOpen(true);
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
Keep the palette simple:
- Fuzzy search over page names, recent actions, settings
- Show keyboard shortcuts inline
- Most recent items first
- No categories until you have 20+ commands
Clipboard
Copy and paste should work everywhere the user expects it.
- One-click copy on codes, URLs, API keys, IDs
- Paste from clipboard into file uploads, image fields
- Show brief confirmation on copy ("Copied!") that auto-dismisses
async function copyToClipboard(text, label = "Copied") {
await navigator.clipboard.writeText(text);
toast(label, { duration: 1500 });
}
Hit targets
Larger hit targets for buttons and inputs. Minimum 44x44px touch targets (WCAG 2.5.8). On desktop, generous padding is still faster than precise aim.
button, .btn, [role="button"] {
min-height: 44px;
min-width: 44px;
padding: 10px 20px;
}
input, select, textarea {
min-height: 44px;
padding: 10px 12px;
font-size: 16px; /* Prevents iOS zoom on focus */
}
- Adjacent clickable elements need at least 8px gap
- Icon-only buttons get larger padding than labeled buttons
- Don't rely on hover states for critical affordances — they don't exist on touch
Honest cancellation
One-click cancel. No guilt trips, no dark patterns, no "Are you sure you want to miss out?"
- Cancel button is always visible alongside confirm
- Account deletion works on the first try
- Unsubscribe is one click, not a preference center
- Downgrade flows don't require contacting support
Tooltips
Very minimal. Tooltips are a confession that the UI doesn't speak for itself.
- Only on icon-only buttons (to provide the label)
- Never on text that's already readable
- Show on hover after 300ms delay, not instantly
- Dismiss on scroll
- Never use tooltips for essential information
Copy
Active voice. Max 7 words per sentence. Talk like a person, not a legal document.
Good: "Project created"
"Saved 2 minutes ago"
"Delete this file?"
Bad: "Your project has been successfully created!"
"Changes were last saved approximately 2 minutes ago"
"Are you sure you want to permanently delete this file? This action cannot be undone."
- Buttons are verbs: "Save", "Delete", "Send" — not "Submit", "OK", "Confirm"
- Error messages say what happened and what to do next
- Never blame the user ("Invalid input" → "Enter a valid email")
- Use sentence case everywhere, never Title Case in UI copy
Optical alignment
Optical alignment over geometric alignment. The eye doesn't see pixels, it sees weight.
- Play icons shift 2-3px right inside circles to look centered
- Text with leading capital letters aligns optically left of its bounding box
- Icons next to text need 1-2px vertical offset depending on the glyph
- Padding around text is visually balanced, not mathematically equal — bottom padding is often 1-2px more than top
/* Geometric center ≠ optical center */
.play-button svg {
transform: translateX(2px);
}
/* Visually balanced card padding */
.card {
padding: 20px 24px 22px 24px;
}
Left-to-right reading flow
Optimized for L-to-R reading and the F-pattern scan.
- Most important content in the top-left quadrant
- Primary actions on the right (where the eye ends a line)
- Labels above inputs, not beside them
- Tables: most-scanned column is leftmost
- Don't center-align body text — left-align everything except single-line headings
Reassurance about loss
Users fear losing work. Prevent it and prove it.
- Auto-save with visible "Saved" indicator and timestamp
- Undo after destructive actions (soft delete, not hard delete)
- "You have unsaved changes" on navigation away
- Version history for anything longer than a tweet
- Confirmation only for irreversible actions, not routine ones
// Warn on unsaved changes
useEffect(() => {
function handleBeforeUnload(e) {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = "";
}
}
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [hasUnsavedChanges]);
Copyable brand assets
Ship a /brand or /press page with a downloadable SVG logo and brand kit. Don't make people screenshot your logo.
- SVG logo with transparent background
- Color codes (hex, RGB, HSL)
- Font names and weights
- Usage guidelines (minimum size, clear space, don'ts)
- One-click download as ZIP
Checklist
Use this when reviewing any web UI:
- Every interaction under 100ms (or optimistically faked)
- No product tour or onboarding modal
- URLs are short, readable, no UUIDs
- State persists across sessions and page reloads
- 3 colors max (plus grays)
- No visible scrollbars at rest
- Any destination reachable in 3 steps or fewer
- SVG logo and brand kit downloadable
- Skeleton loaders, not spinners
- Clipboard copy works on codes, keys, URLs
- Touch targets 44px minimum
- Cancel is honest and one-click
- Cmd+K command palette exists
- Tooltips only on icon-only buttons
- Copy is active voice, 7 words max
- Optical alignment, not geometric
- Content follows L-to-R F-pattern
- Auto-save with visible status and undo