Responsive Design Implementation Patterns
1. CSS Grid Flexbox Layout Systems
| Property | Syntax | Description | Use Case |
|---|---|---|---|
| CSS Grid | display: grid |
2D layout system for rows and columns | Page layouts, complex grids |
| grid-template-columns | repeat(auto-fit, minmax(250px, 1fr)) |
Responsive columns without media queries | Card grids, galleries |
| Flexbox | display: flex |
1D layout for rows or columns | Navigation, components |
| flex-wrap | flex-wrap: wrap |
Items wrap to next line when space runs out | Responsive lists |
| gap | gap: 1rem |
Spacing between grid/flex items | Consistent spacing |
| Grid Areas | grid-template-areas |
Named template areas for semantic layouts | Complex page structures |
Example: CSS Grid and Flexbox responsive layouts
/* Responsive Grid - Auto-fit with minmax */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
padding: 1rem;
}
/* Responsive without media queries! */
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* CSS Grid with named areas */
.page-layout {
display: grid;
grid-template-areas:
"header header header"
"sidebar main aside"
"footer footer footer";
grid-template-columns: 200px 1fr 200px;
grid-template-rows: auto 1fr auto;
gap: 1rem;
min-height: 100vh;
}
.header { grid-area: header; }
.sidebar { grid-area: sidebar; }
.main { grid-area: main; }
.aside { grid-area: aside; }
.footer { grid-area: footer; }
/* Responsive grid areas */
@media (max-width: 768px) {
.page-layout {
grid-template-areas:
"header"
"main"
"sidebar"
"aside"
"footer";
grid-template-columns: 1fr;
}
}
/* Flexbox responsive navigation */
.nav {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
}
.nav-links {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
/* Responsive flex direction */
.container {
display: flex;
flex-direction: row;
gap: 2rem;
}
@media (max-width: 768px) {
.container {
flex-direction: column;
}
}
/* Holy Grail Layout with Flexbox */
.holy-grail {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.holy-grail-body {
display: flex;
flex: 1;
}
.holy-grail-content {
flex: 1;
}
.holy-grail-nav,
.holy-grail-ads {
flex: 0 0 12em;
}
.holy-grail-nav {
order: -1;
}
@media (max-width: 768px) {
.holy-grail-body {
flex-direction: column;
}
.holy-grail-nav,
.holy-grail-ads {
order: 0;
}
}
/* Advanced Grid - Masonry-style layout */
.masonry {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-auto-rows: 10px;
gap: 1rem;
}
.masonry-item {
grid-row-end: span 20; /* Adjust based on content height */
}
/* Subgrid (modern browsers) */
.parent-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.child-grid {
display: grid;
grid-template-columns: subgrid;
grid-column: span 3;
}
2. Tailwind CSS Responsive Utilities
| Breakpoint | Syntax | Min-Width | Example |
|---|---|---|---|
| sm | sm:text-lg |
640px | Small devices (tablets) |
| md | md:flex-row |
768px | Medium devices (small laptops) |
| lg | lg:grid-cols-3 |
1024px | Large devices (desktops) |
| xl | xl:container |
1280px | Extra large screens |
| 2xl | 2xl:px-8 |
1536px | Ultra-wide screens |
| Custom | theme.screens |
Configurable | Custom breakpoints |
Example: Tailwind CSS responsive design patterns
// tailwind.config.js - Custom breakpoints
module.exports = {
theme: {
screens: {
'xs': '475px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px',
'3xl': '1920px',
},
extend: {
spacing: {
'128': '32rem',
},
},
},
};
{/* Responsive grid with Tailwind */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<div className="bg-white p-4 rounded-lg shadow">Card 1</div>
<div className="bg-white p-4 rounded-lg shadow">Card 2</div>
<div className="bg-white p-4 rounded-lg shadow">Card 3</div>
</div>
{/* Mobile-first responsive typography */}
<h1 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl font-bold">
Responsive Heading
</h1>
{/* Responsive flex direction */}
<div className="flex flex-col md:flex-row gap-4">
<aside className="w-full md:w-1/4 bg-gray-100 p-4">Sidebar</aside>
<main className="w-full md:w-3/4 p-4">Main Content</main>
</div>
{/* Responsive visibility */}
<button className="hidden md:block">Desktop Only</button>
<button className="md:hidden">Mobile Only</button>
{/* Responsive padding and margins */}
<div className="px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16">
<div className="max-w-7xl mx-auto">
Centered content with responsive padding
</div>
</div>
{/* Responsive navigation */}
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
Logo
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<a href="#" className="border-b-2 px-3 py-2">Home</a>
<a href="#" className="px-3 py-2">About</a>
<a href="#" className="px-3 py-2">Contact</a>
</div>
</div>
<div className="sm:hidden">
<button>Menu</button>
</div>
</div>
</div>
</nav>
{/* Responsive container with breakpoint-specific max-widths */}
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
{/* Container automatically adjusts max-width at each breakpoint */}
</div>
{/* Arbitrary values for custom responsive design */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
Custom grid
</div>
{/* Responsive aspect ratios */}
<div className="aspect-video md:aspect-square lg:aspect-[16/9]">
<img src="image.jpg" className="w-full h-full object-cover" />
</div>
{/* Dark mode + responsive */}
<div className="bg-white dark:bg-gray-800 p-4 sm:p-6 md:p-8">
<h2 className="text-gray-900 dark:text-white text-lg sm:text-xl md:text-2xl">
Responsive dark mode content
</h2>
</div>
3. Container Queries CSS Modern NEW
| Property | Syntax | Description | Advantage |
|---|---|---|---|
| container-type | container-type: inline-size |
Establishes containment context | Component-level responsiveness |
| container-name | container-name: card |
Names container for querying | Multiple container contexts |
| @container | @container (min-width: 400px) |
Query container dimensions, not viewport | Truly reusable components |
| cqw/cqh | width: 50cqw |
Container query width/height units | Fluid component sizing |
| container shorthand | container: card / inline-size |
Combined name and type declaration | Cleaner syntax |
Example: Container queries for component-based responsiveness
/* Container query setup */
.card-container {
container-type: inline-size;
container-name: card;
}
/* Component styles based on container width */
.card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 8px;
}
/* When container is at least 400px wide */
@container card (min-width: 400px) {
.card {
flex-direction: row;
align-items: center;
}
.card-image {
width: 200px;
height: 200px;
}
.card-content {
flex: 1;
}
}
/* When container is at least 600px wide */
@container card (min-width: 600px) {
.card {
padding: 2rem;
}
.card-title {
font-size: 2rem;
}
}
/* Product card with container queries */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.product-card {
container-type: inline-size;
background: white;
border-radius: 12px;
overflow: hidden;
}
.product-content {
padding: 1rem;
}
.product-details {
display: none; /* Hidden by default */
}
/* Show details when card is wide enough */
@container (min-width: 350px) {
.product-details {
display: block;
}
.product-button {
width: 100%;
}
}
@container (min-width: 500px) {
.product-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
/* Container query units */
.responsive-text {
container-type: inline-size;
}
.responsive-text h1 {
font-size: calc(5cqw + 1rem);
/* Font size relative to container width */
}
/* Named containers with Tailwind (plugin) */
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
@container sidebar (min-width: 300px) {
.sidebar-nav {
flex-direction: row;
}
}
/* Nested containers */
.page {
container-type: inline-size;
container-name: page;
}
.section {
container-type: inline-size;
container-name: section;
}
/* Query specific container */
@container page (min-width: 1200px) {
.page-header {
display: flex;
}
}
@container section (min-width: 400px) {
.section-content {
columns: 2;
}
}
/* React component with container queries */
function ProductCard({ product }) {
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<div className="product-content">
<h3>{product.name}</h3>
<p className="product-price">${product.price}</p>
<p className="product-details">{product.description}</p>
<button className="product-button">Add to Cart</button>
</div>
</div>
);
}
4. Intersection Observer Lazy Loading
| Feature | Syntax | Description | Use Case |
|---|---|---|---|
| IntersectionObserver | new IntersectionObserver(callback) |
Observes element visibility in viewport | Lazy loading, infinite scroll |
| threshold | { threshold: 0.5 } |
Percentage of element visible to trigger | Precise loading control |
| rootMargin | { rootMargin: '100px' } |
Margin around viewport to trigger early | Preload before visible |
| observe() | observer.observe(element) |
Start observing an element | Track visibility |
| unobserve() | observer.unobserve(element) |
Stop observing an element | Cleanup after loading |
| loading="lazy" | <img loading="lazy"> |
Native browser lazy loading | Simple image/iframe lazy load |
Example: Intersection Observer for lazy loading
// Vanilla JS lazy loading images
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, {
rootMargin: '50px', // Load 50px before entering viewport
threshold: 0.1,
});
images.forEach(img => imageObserver.observe(img));
// React lazy loading component
import { useEffect, useRef, useState } from 'react';
function LazyImage({ src, alt, placeholder }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '100px' }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isInView ? src : placeholder}
alt={alt}
onLoad={() => setIsLoaded(true)}
style={{
opacity: isLoaded ? 1 : 0.5,
transition: 'opacity 0.3s',
}}
/>
);
}
// Infinite scroll implementation
function InfiniteScroll() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
},
{ threshold: 1.0 }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [hasMore, page]);
const loadMore = async () => {
const newItems = await fetchItems(page);
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
setHasMore(newItems.length > 0);
};
return (
<div>
{items.map(item => <div key={item.id}>{item.name}</div>)}
<div ref={loaderRef}>{hasMore && 'Loading...'}</div>
</div>
);
}
// Custom hook for intersection observer
function useIntersectionObserver(
ref: React.RefObject<Element>,
options: IntersectionObserverInit = {}
) {
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect();
}, [ref, options]);
return isIntersecting;
}
// Usage
function AnimatedSection() {
const ref = useRef(null);
const isVisible = useIntersectionObserver(ref, { threshold: 0.5 });
return (
<div
ref={ref}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(50px)',
transition: 'all 0.6s ease-out',
}}
>
Content fades in when 50% visible
</div>
);
}
// Native lazy loading (simpler approach)
<img
src="image.jpg"
alt="Description"
loading="lazy"
decoding="async"
/>
<iframe
src="https://example.com"
loading="lazy"
></iframe>
// Background image lazy loading
function LazyBackground({ imageUrl, children }) {
const [isLoaded, setIsLoaded] = useState(false);
const divRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
setIsLoaded(true);
observer.disconnect();
}
});
if (divRef.current) {
observer.observe(divRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div
ref={divRef}
style={{
backgroundImage: isLoaded ? `url(${imageUrl})` : 'none',
backgroundColor: '#f0f0f0',
backgroundSize: 'cover',
}}
>
{children}
</div>
);
}
5. Mobile-First Breakpoint Strategy
| Breakpoint | Device | Approach | Best Practice |
|---|---|---|---|
| Base (mobile) | 320px - 639px | Default styles, no media query | Single column, stack elements |
| sm (tablet) | 640px+ | @media (min-width: 640px) |
2 columns, larger text |
| md (laptop) | 768px+ | @media (min-width: 768px) |
Sidebar layouts, 3 columns |
| lg (desktop) | 1024px+ | @media (min-width: 1024px) |
Multi-column grids, hover states |
| xl (wide) | 1280px+ | @media (min-width: 1280px) |
Max content width, more spacing |
| Touch-first | All mobile | 44px touch targets, no hover | Accessibility, usability |
Example: Mobile-first responsive design
/* Mobile-first CSS approach */
/* Base styles (mobile, no media query) */
.container {
width: 100%;
padding: 1rem;
}
.grid {
display: grid;
grid-template-columns: 1fr; /* Single column on mobile */
gap: 1rem;
}
.nav {
flex-direction: column;
}
.button {
min-height: 44px; /* Touch-friendly target */
width: 100%;
font-size: 16px; /* Prevents zoom on iOS */
}
/* Tablet (640px+) */
@media (min-width: 640px) {
.container {
padding: 2rem;
}
.grid {
grid-template-columns: repeat(2, 1fr);
}
.button {
width: auto;
min-width: 120px;
}
}
/* Laptop (768px+) */
@media (min-width: 768px) {
.container {
max-width: 768px;
margin: 0 auto;
}
.grid {
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.nav {
flex-direction: row;
justify-content: space-between;
}
/* Show hover states only on larger screens */
.button:hover {
background-color: #0056b3;
}
}
/* Desktop (1024px+) */
@media (min-width: 1024px) {
.container {
max-width: 1024px;
}
.grid {
grid-template-columns: repeat(4, 1fr);
}
.sidebar {
display: block; /* Show sidebar on desktop */
}
}
/* Wide screens (1280px+) */
@media (min-width: 1280px) {
.container {
max-width: 1280px;
padding: 3rem;
}
}
/* React mobile-first component */
function ResponsiveCard() {
return (
<div className="
/* Mobile base */
flex flex-col
p-4
w-full
/* Tablet */
sm:flex-row
sm:p-6
/* Laptop */
md:max-w-2xl
md:mx-auto
/* Desktop */
lg:max-w-4xl
lg:p-8
">
<div className="
/* Mobile: full width image */
w-full
h-48
/* Tablet: side image */
sm:w-1/3
sm:h-auto
/* Laptop: larger */
md:w-1/4
">
<img src="image.jpg" alt="Card" className="w-full h-full object-cover" />
</div>
<div className="
/* Mobile: full width content */
w-full
mt-4
/* Tablet: beside image */
sm:w-2/3
sm:mt-0
sm:pl-6
/* Laptop: more space */
md:w-3/4
md:pl-8
">
<h2 className="text-xl sm:text-2xl md:text-3xl lg:text-4xl">
Responsive Title
</h2>
<p className="mt-2 text-sm sm:text-base md:text-lg">
Description text
</p>
</div>
</div>
);
}
/* Mobile-first typography scale */
:root {
/* Mobile base sizes */
--text-xs: 0.75rem;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.25rem;
--text-2xl: 1.5rem;
}
@media (min-width: 768px) {
:root {
/* Larger text on desktop */
--text-base: 1.125rem;
--text-lg: 1.25rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
}
}
/* Touch-first interactions */
.interactive {
/* Minimum touch target size */
min-width: 44px;
min-height: 44px;
/* No hover effects on touch devices */
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
/* Only add hover effects on devices that support hover */
.interactive:hover {
background-color: #f0f0f0;
}
}
/* Mobile-first navigation */
.mobile-nav {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: white;
transform: translateX(-100%);
transition: transform 0.3s;
}
.mobile-nav.open {
transform: translateX(0);
}
@media (min-width: 768px) {
.mobile-nav {
position: static;
flex-direction: row;
width: auto;
height: auto;
transform: none;
}
}
6. Adaptive Loading Network Conditions
| API | Property | Description | Use Case |
|---|---|---|---|
| Network Information | navigator.connection |
Access network connection info | Adapt to connection quality |
| effectiveType | connection.effectiveType |
'4g', '3g', '2g', 'slow-2g' | Load different assets |
| saveData | connection.saveData |
User enabled data saver mode | Reduce data usage |
| deviceMemory | navigator.deviceMemory |
Device RAM in GB | Load lighter alternatives |
| hardwareConcurrency | navigator.hardwareConcurrency |
Number of CPU cores | Adjust processing tasks |
| Adaptive Loading | react-adaptive-hooks |
React hooks for adaptive loading | Component-level adaptation |
Example: Adaptive loading based on network and device
// Check network connection
function getNetworkInfo() {
const connection = navigator.connection
|| navigator.mozConnection
|| navigator.webkitConnection;
if (!connection) {
return { effectiveType: '4g', saveData: false };
}
return {
effectiveType: connection.effectiveType,
saveData: connection.saveData,
downlink: connection.downlink,
rtt: connection.rtt,
};
}
// Adaptive image loading
function AdaptiveImage({ src, alt }) {
const network = getNetworkInfo();
// Load different image quality based on connection
const imageSrc = network.effectiveType === '4g' && !network.saveData
? src.high
: network.effectiveType === '3g'
? src.medium
: src.low;
return <img src={imageSrc} alt={alt} loading="lazy" />;
}
// React hook for network status
import { useEffect, useState } from 'react';
function useNetworkStatus() {
const [status, setStatus] = useState({
online: navigator.onLine,
effectiveType: '4g',
saveData: false,
});
useEffect(() => {
const connection = navigator.connection;
const updateNetworkStatus = () => {
setStatus({
online: navigator.onLine,
effectiveType: connection?.effectiveType || '4g',
saveData: connection?.saveData || false,
});
};
updateNetworkStatus();
window.addEventListener('online', updateNetworkStatus);
window.addEventListener('offline', updateNetworkStatus);
connection?.addEventListener('change', updateNetworkStatus);
return () => {
window.removeEventListener('online', updateNetworkStatus);
window.removeEventListener('offline', updateNetworkStatus);
connection?.removeEventListener('change', updateNetworkStatus);
};
}, []);
return status;
}
// Usage
function VideoPlayer({ videoUrl }) {
const { effectiveType, saveData, online } = useNetworkStatus();
if (!online) {
return <div>You are offline</div>;
}
// Adapt video quality
const quality = saveData || effectiveType === '2g' ? 'low'
: effectiveType === '3g' ? 'medium'
: 'high';
return (
<video src={videoUrl[quality]} controls autoPlay={effectiveType === '4g'} />
);
}
// Device capability detection
function useDeviceCapabilities() {
return {
memory: navigator.deviceMemory || 4,
cores: navigator.hardwareConcurrency || 2,
platform: navigator.platform,
};
}
// Adaptive component loading
function Dashboard() {
const { memory, cores } = useDeviceCapabilities();
const { effectiveType, saveData } = useNetworkStatus();
// Load heavy components only on capable devices with good connection
const shouldLoadHeavyFeatures =
memory >= 4 &&
cores >= 4 &&
effectiveType === '4g' &&
!saveData;
return (
<div>
<BasicDashboard />
{shouldLoadHeavyFeatures && (
<Suspense fallback={<Skeleton />}>
<HeavyChart />
<RealTimeData />
</Suspense>
)}
</div>
);
}
// Adaptive image component with multiple strategies
function SmartImage({ src, alt, sizes }) {
const { effectiveType, saveData } = useNetworkStatus();
const { memory } = useDeviceCapabilities();
// Preload critical images on fast connections
useEffect(() => {
if (effectiveType === '4g' && !saveData) {
const link = document.createElement('link');
link.rel = 'preload';
link.as = 'image';
link.href = src;
document.head.appendChild(link);
}
}, [src, effectiveType, saveData]);
// Format based on device capability
const format = memory >= 4 ? 'webp' : 'jpg';
return (
<picture>
{!saveData && effectiveType === '4g' && (
<source srcSet={`${src}.${format} 2x`} type={`image/${format}`} />
)}
<source srcSet={`${src}.${format}`} type={`image/${format}`} />
<img src={`${src}.jpg`} alt={alt} loading="lazy" />
</picture>
);
}
// react-adaptive-hooks library
import {
useNetworkStatus,
useSaveData,
useHardwareConcurrency,
useMemoryStatus,
} from 'react-adaptive-hooks';
function AdaptiveApp() {
const { effectiveConnectionType } = useNetworkStatus();
const { saveData } = useSaveData();
const { numberOfLogicalProcessors } = useHardwareConcurrency();
const { deviceMemory } = useMemoryStatus();
const isLowEndDevice = deviceMemory < 4 || numberOfLogicalProcessors < 4;
const isSlowNetwork = effectiveConnectionType === '2g' || effectiveConnectionType === '3g';
return (
<div>
{isLowEndDevice || isSlowNetwork || saveData ? (
<LightweightComponent />
) : (
<FeatureRichComponent />
)}
</div>
);
}
// Adaptive font loading
if (navigator.connection?.effectiveType === '4g' && !navigator.connection?.saveData) {
// Load custom fonts
document.fonts.load('16px CustomFont').then(() => {
document.body.classList.add('fonts-loaded');
});
} else {
// Use system fonts
document.body.style.fontFamily = 'system-ui, sans-serif';
}
Responsive Design Best Practices
- Mobile-First - Start with mobile styles, enhance for larger screens with min-width media queries
- CSS Grid + Flexbox - Use Grid for 2D layouts, Flexbox for 1D components, leverage auto-fit/minmax for responsiveness
- Container Queries - Component-based responsiveness, truly reusable components independent of viewport
- Tailwind Utilities - Rapid responsive development with sm/md/lg/xl breakpoint prefixes
- Lazy Loading - Use Intersection Observer for images, components, infinite scroll; native loading="lazy" for simple cases
- Adaptive Loading - Detect network conditions (4g/3g/2g), save-data mode, device memory to serve appropriate assets
- Touch-First - Minimum 44px touch targets, avoid hover-only interactions, use pointer events for unified input handling