Responsive Patterns
Responsive design with Container Queries, fluid typography, cqi/cqb units, subgrid, intrinsic layouts, foldable devices, and mobile-first patterns for React applications. Use when building responsive layouts or container queries.
Auto-activated — this skill loads automatically when Claude detects matching context.
Responsive Patterns
Modern responsive design patterns using Container Queries, fluid typography, and mobile-first strategies for React applications (2026 best practices).
Overview
- Building reusable components that adapt to their container
- Implementing fluid typography that scales smoothly
- Creating responsive layouts without media query overload
- Building design system components for multiple contexts
- Optimizing for variable container sizes (sidebars, modals, grids)
Core Concepts
Container Queries vs Media Queries
| Feature | Media Queries | Container Queries |
|---|---|---|
| Responds to | Viewport size | Container size |
| Component reuse | Context-dependent | Truly portable |
| Browser support | Universal | Baseline 2023+ |
| Use case | Page layouts | Component layouts |
Modern CSS Layout
Load
Read("$\{CLAUDE_SKILL_DIR\}/rules/css-subgrid.md")for CSS Subgrid patterns: nested grid alignment, card layouts with aligned titles/content/actions, two-dimensional subgrid.
Load
Read("$\{CLAUDE_SKILL_DIR\}/rules/css-intrinsic-responsive.md")for intrinsically responsive layouts: auto-fit/minmax grids, clamp() for fluid everything, container queries for component logic, zero media query patterns.
Load
Read("$\{CLAUDE_SKILL_DIR\}/rules/responsive-foldables.md")for foldable/multi-screen device support: env(safe-area-inset-*), viewport segment queries, dual-screen layouts, progressive enhancement.
Key patterns covered: CSS Subgrid alignment, intrinsic responsive grids (auto-fit + minmax), fluid clamp() scales, foldable device layouts, safe area insets, viewport segment queries.
CSS Patterns
Load
Read("$\{CLAUDE_SKILL_DIR\}/rules/css-patterns.md")for complete CSS examples: container queries, cqi/cqb units, fluid typography with clamp(), mobile-first breakpoints, CSS Grid patterns, and scroll-queries.
Key patterns covered: Container Query basics, Container Query Units (cqi/cqb), Fluid Typography with clamp(), Container-Based Fluid Typography, Mobile-First Breakpoints, CSS Grid Responsive Patterns, Container Scroll-Queries (Chrome 126+).
React Patterns
Load
Read("$\{CLAUDE_SKILL_DIR\}/rules/react-patterns.md")for complete React examples: ResponsiveCard component, Tailwind container queries, useContainerQuery hook, and responsive images.
Key patterns covered: Responsive Component with Container Queries, Tailwind CSS Container Queries, useContainerQuery Hook, Responsive Images Pattern.
Accessibility Considerations
/* IMPORTANT: Always include rem in fluid typography */
/* This ensures user font preferences are respected */
/* ❌ WRONG: Viewport-only ignores user preferences */
font-size: 5vw;
/* ✅ CORRECT: Include rem to respect user settings */
font-size: clamp(1rem, 0.5rem + 2vw, 2rem);
/* User zooming must still work */
@media (min-width: 768px) {
/* Use em/rem, not px, for breakpoints in ideal world */
/* (browsers still use px, but consider user zoom) */
}Anti-Patterns (FORBIDDEN)
/* ❌ NEVER: Use only viewport units for text */
.title {
font-size: 5vw; /* Ignores user font preferences! */
}
/* ❌ NEVER: Use cqw/cqh (use cqi/cqb instead) */
.card {
padding: 5cqw; /* cqw = container width, not logical */
}
/* ✅ CORRECT: Use logical units */
.card {
padding: 5cqi; /* Container inline = logical direction */
}
/* ❌ NEVER: Container queries without container-type */
@container (min-width: 400px) {
/* Won't work without container-type on parent! */
}
/* ❌ NEVER: Desktop-first media queries */
.element {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 768px) {
.element {
grid-template-columns: 1fr; /* Overriding = more CSS */
}
}
/* ❌ NEVER: Fixed pixel breakpoints for text */
@media (min-width: 768px) {
body { font-size: 18px; } /* Use rem! */
}
/* ❌ NEVER: Over-nesting container queries */
@container a {
@container b {
@container c {
/* Too complex, reconsider architecture */
}
}
}Browser Support
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
| Container Size Queries | 105+ | 16+ | 110+ | 105+ |
| Container Style Queries | 111+ | ❌ | ❌ | 111+ |
| Container Scroll-State | 126+ | ❌ | ❌ | 126+ |
| cqi/cqb units | 105+ | 16+ | 110+ | 105+ |
| clamp() | 79+ | 13.1+ | 75+ | 79+ |
| Subgrid | 117+ | 16+ | 71+ | 117+ |
Rules
Each category has individual rule files in rules/ loaded on-demand:
| Category | Rule | Impact | Key Pattern |
|---|---|---|---|
| Modern CSS Layout | rules/css-subgrid.md | HIGH | CSS Subgrid for nested grid alignment, card layouts |
| Modern CSS Layout | rules/css-intrinsic-responsive.md | HIGH | Intrinsic responsive layouts, auto-fit/minmax, clamp(), zero breakpoints |
| Modern CSS Layout | rules/responsive-foldables.md | MEDIUM | Foldable devices, safe area insets, viewport segments |
| CSS | rules/css-patterns.md | HIGH | Container queries, cqi/cqb, fluid typography, grid, scroll-queries |
| React | rules/react-patterns.md | HIGH | Container query components, Tailwind, useContainerQuery, responsive images |
| PWA | rules/pwa-service-worker.md | HIGH | Workbox caching strategies, VitePWA, update management |
| PWA | rules/pwa-offline.md | HIGH | Offline hooks, background sync, install prompts |
| Animation | rules/animation-motion.md | HIGH | Motion presets, AnimatePresence, View Transitions |
| Animation | rules/animation-scroll.md | MEDIUM | CSS scroll-driven animations, parallax, progressive enhancement |
| Touch & Mobile | rules/touch-interaction.md | HIGH | Touch targets (44px min), thumb zones, pinch-to-zoom, safe areas, gestures |
Total: 10 rules across 6 categories
Key Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Query type | Media queries | Container queries | Container for components, Media for layout |
| Container units | cqw/cqh | cqi/cqb | cqi/cqb (logical, i18n-ready) |
| Fluid type base | vw only | rem + vw | rem + vw (accessibility) |
| Mobile-first | Yes | Desktop-first | Mobile-first (less CSS, progressive) |
| Grid pattern | auto-fit | auto-fill | auto-fit for cards, auto-fill for icons |
Related Skills
design-system-starter- Building responsive design systemsork:performance- CLS, responsive images, and image optimizationork:i18n-date-patterns- RTL/LTR responsive considerations
Capability Details
container-queries
Keywords: @container, container-type, inline-size, container-name Solves: Component-level responsive design
fluid-typography
Keywords: clamp(), fluid, vw, rem, scale, typography Solves: Smooth font scaling without breakpoints
responsive-images
Keywords: srcset, sizes, picture, art direction Solves: Responsive images for different viewports
mobile-first-strategy
Keywords: min-width, mobile, progressive, breakpoints Solves: Efficient responsive CSS architecture
grid-flexbox-patterns
Keywords: auto-fit, auto-fill, subgrid, minmax Solves: Responsive grid and flexbox layouts
container-units
Keywords: cqi, cqb, container width, container height Solves: Sizing relative to container dimensions
References
Load on demand with Read("$\{CLAUDE_SKILL_DIR\}/references/<file>"):
| File | Content |
|---|---|
container-queries.md | Container query patterns |
fluid-typography.md | Accessible fluid type scales |
Rules (10)
Use Motion library with centralized presets for 60fps animations and reduced-motion compliance — HIGH
Motion Library & View Transitions
Use Motion (Framer Motion) for React component animations and View Transitions API for page navigation with consistent presets.
Incorrect — inline animation values without presets:
// WRONG: Inconsistent animation values, no reduced-motion support
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.7, ease: "easeInOut" }}
>
{/* Different values everywhere, no consistency */}
</motion.div>Correct — centralized presets with AnimatePresence:
// lib/animations.ts — Single source of truth
export const fadeInUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
transition: { type: 'spring', stiffness: 300, damping: 30 },
};
export const staggerContainer = {
animate: { transition: { staggerChildren: 0.05 } },
};
export const staggerItem = {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
};
// Usage with AnimatePresence for exit animations
import { motion, AnimatePresence } from 'motion/react';
import { staggerContainer, staggerItem, fadeInUp } from '@/lib/animations';
function AnimatedList({ items }: { items: Item[] }) {
return (
<AnimatePresence mode="wait">
<motion.ul variants={staggerContainer} initial="initial" animate="animate">
{items.map((item) => (
<motion.li key={item.id} variants={staggerItem} layout>
{item.name}
</motion.li>
))}
</motion.ul>
</AnimatePresence>
);
}View Transitions API for page navigation:
// React Router with View Transitions
import { Link, useViewTransitionState } from 'react-router';
function ProductCard({ product }: { product: Product }) {
const isTransitioning = useViewTransitionState(`/products/${product.id}`);
return (
<Link to={`/products/${product.id}`} viewTransition>
<img
src={product.image}
alt={product.name}
style={{
viewTransitionName: isTransitioning ? 'product-image' : undefined,
}}
/>
</Link>
);
}Key rules:
- Animate only
transformandopacityfor 60fps (hardware accelerated) - Use centralized preset files, never inline animation values
- Always wrap conditional renders in
AnimatePresencefor exit animations - Respect
prefers-reduced-motion: reduce— disable or shorten animations - Keep transitions under 400ms to avoid blocking user interaction
- Use View Transitions API for page navigation, Motion for component animations
Use CSS scroll-driven animations on the compositor thread for guaranteed 60fps performance — MEDIUM
Scroll-Driven Animations
Use CSS Scroll-Driven Animations API for performant, declarative scroll-linked effects without JavaScript.
Incorrect — JavaScript scroll listener (jank-prone):
// WRONG: Scroll listeners run on main thread, cause jank
window.addEventListener('scroll', () => {
const progress = window.scrollY / document.body.scrollHeight;
progressBar.style.width = `${progress * 100}%`; // Layout thrashing!
parallaxElement.style.transform = `translateY(${window.scrollY * 0.5}px)`;
});Correct — CSS Scroll-Driven Animations (compositor thread):
/* Reading progress bar — zero JavaScript */
.progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 4px;
background: var(--color-primary);
transform-origin: left;
animation: grow-progress linear;
animation-timeline: scroll(root block);
}
@keyframes grow-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* Reveal on scroll — element enters viewport */
.reveal-on-scroll {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Parallax section */
.parallax-bg {
animation: parallax-shift linear;
animation-timeline: scroll(root block);
}
@keyframes parallax-shift {
from { transform: translateY(-20%); }
to { transform: translateY(20%); }
}Progressive enhancement (required for browser support):
/* Feature detection — fallback for unsupported browsers */
@supports (animation-timeline: view()) {
.reveal-on-scroll {
animation: fade-in-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
}
/* Fallback: IntersectionObserver in JavaScript */
@supports not (animation-timeline: view()) {
.reveal-on-scroll {
opacity: 0;
transform: translateY(30px);
transition: opacity 0.5s, transform 0.5s;
}
.reveal-on-scroll.visible {
opacity: 1;
transform: translateY(0);
}
}Browser support:
| Feature | Chrome | Safari | Firefox | Edge |
|---|---|---|---|---|
| Scroll-Driven CSS | 115+ | 18.4+ | In dev | 115+ |
scroll() function | 115+ | 18.4+ | In dev | 115+ |
view() function | 115+ | 18.4+ | In dev | 115+ |
Key rules:
- CSS Scroll-Driven Animations run on compositor thread = guaranteed 60fps
- Always use
@supports (animation-timeline: view())for progressive enhancement - Use
scroll(root block)for page-level progress,view()for element visibility animation-rangecontrols when animation starts/ends relative to viewport- Never use JavaScript scroll listeners for visual effects (use CSS or IntersectionObserver)
- Respect
prefers-reduced-motion— disable parallax and motion effects
Use intrinsically responsive layouts with auto-fit, clamp, and container queries instead of media query breakpoints — HIGH
Intrinsically Responsive Layouts
Build layouts that adapt to available space without media queries. Use auto-fit/minmax() for grids, clamp() for fluid values, and container queries for component logic.
Incorrect — many media query breakpoints for component internals:
/* WRONG: Breakpoint soup for a simple card grid */
.card-grid {
display: grid;
grid-template-columns: 1fr;
}
@media (min-width: 480px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 1280px) { .card-grid { grid-template-columns: repeat(5, 1fr); } }
.card-title { font-size: 1rem; }
@media (min-width: 768px) { .card-title { font-size: 1.25rem; } }
@media (min-width: 1024px) { .card-title { font-size: 1.5rem; } }Correct — intrinsic sizing, zero breakpoints:
/* Auto-adapting grid: columns appear/disappear based on space */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: clamp(1rem, 2vw, 2rem);
}
/* Fluid typography: scales smoothly without breakpoints */
.card-title {
font-size: clamp(1rem, 0.8rem + 1vw, 1.5rem);
}
/* Fluid spacing: padding and margins adapt to viewport */
.card {
padding: clamp(1rem, 3vw, 2rem);
border-radius: clamp(0.5rem, 1vw, 1rem);
}Container queries for component-level responsiveness:
/* Component adapts to its container, not the viewport */
.widget-wrapper {
container-type: inline-size;
container-name: widget;
}
@container widget (min-width: 400px) {
.widget { flex-direction: row; }
}
@container widget (max-width: 399px) {
.widget { flex-direction: column; }
}When viewport queries ARE appropriate:
/* Viewport queries for page-level structural changes only */
@media (min-width: 768px) {
.page-layout {
display: grid;
grid-template-columns: 250px 1fr;
/* Sidebar appears — this is page structure, not component logic */
}
}Fluid clamp() patterns for common properties:
:root {
/* Fluid font scale */
--text-sm: clamp(0.875rem, 0.8rem + 0.25vw, 1rem);
--text-md: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--text-lg: clamp(1.25rem, 1rem + 1vw, 2rem);
/* Fluid spacing scale */
--space-sm: clamp(0.5rem, 1vw, 1rem);
--space-md: clamp(1rem, 2vw, 2rem);
--space-lg: clamp(1.5rem, 4vw, 4rem);
/* Fluid gap */
--gap: clamp(1rem, 2vw, 2rem);
}Key rules:
- Use
repeat(auto-fit, minmax(MIN, 1fr))for grids — columns adapt automatically - Use
clamp(min, preferred, max)for font-size, padding, gap, and margin - Reserve
@mediaqueries for page-level structure (sidebar, nav, footer layout) - Use
@containerqueries for component-level responsive logic - Always include
remin clamp() to respect user font preferences auto-fitcollapses empty tracks (cards fill space);auto-fillkeeps empty tracks
CSS Responsive Patterns — HIGH
CSS Responsive Patterns
1. Container Query Basics
/* Define a query container */
.card-container {
container-type: inline-size;
container-name: card;
}
/* Style based on container width */
@container card (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
}
}
@container card (max-width: 399px) {
.card {
display: flex;
flex-direction: column;
}
}Incorrect:
/* Using media queries for component-level responsiveness */
@media (min-width: 400px) {
.card { display: grid; grid-template-columns: 200px 1fr; }
}Correct:
/* Using container queries — responds to container, not viewport */
.card-container { container-type: inline-size; }
@container (min-width: 400px) {
.card { display: grid; grid-template-columns: 200px 1fr; }
}2. Container Query Units (cqi, cqb)
/* Use cqi (container query inline) over cqw */
.card-title {
/* 5% of container's inline size */
font-size: clamp(1rem, 5cqi, 2rem);
}
.card-content {
/* Responsive padding based on container */
padding: 2cqi;
}
/* cqb for block dimension (height-aware containers) */
.sidebar-item {
height: 10cqb;
}3. Fluid Typography with clamp()
/* Accessible fluid typography */
:root {
/* Base font respects user preferences (rem) */
--font-size-base: 1rem;
/* Fluid scale with min/max bounds */
--font-size-sm: clamp(0.875rem, 0.8rem + 0.25vw, 1rem);
--font-size-md: clamp(1rem, 0.9rem + 0.5vw, 1.25rem);
--font-size-lg: clamp(1.25rem, 1rem + 1vw, 2rem);
--font-size-xl: clamp(1.5rem, 1rem + 2vw, 3rem);
--font-size-2xl: clamp(2rem, 1rem + 3vw, 4rem);
}
h1 { font-size: var(--font-size-2xl); }
h2 { font-size: var(--font-size-xl); }
h3 { font-size: var(--font-size-lg); }
p { font-size: var(--font-size-md); }
small { font-size: var(--font-size-sm); }4. Container-Based Fluid Typography
/* For component-scoped fluid text */
.widget {
container-type: inline-size;
}
.widget-title {
/* Fluid within container, respecting user rem */
font-size: clamp(1rem, 0.5rem + 5cqi, 2rem);
}
.widget-body {
font-size: clamp(0.875rem, 0.5rem + 3cqi, 1.125rem);
}5. Mobile-First Breakpoints
/* Mobile-first: start small, add complexity */
.layout {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Tablet and up */
@media (min-width: 768px) {
.layout {
flex-direction: row;
}
}
/* Desktop */
@media (min-width: 1024px) {
.layout {
max-width: 1200px;
margin-inline: auto;
}
}6. CSS Grid Responsive Patterns
/* Auto-fit grid (fills available space) */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Auto-fill grid (maintains minimum columns) */
.icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 1rem;
}
/* Subgrid for nested alignment */
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3;
}7. Container Scroll-Queries (Chrome 126+)
/* Query based on scroll state */
.scroll-container {
container-type: scroll-state;
container-name: scroller;
}
@container scroller scroll-state(scrollable: top) {
.scroll-indicator-top {
opacity: 0;
}
}
@container scroller scroll-state(scrollable: bottom) {
.scroll-indicator-bottom {
opacity: 0;
}
}Use CSS Subgrid for automatic nested grid alignment instead of duplicating grid definitions — HIGH
CSS Subgrid
Use grid-template-columns: subgrid and grid-template-rows: subgrid to inherit parent grid tracks in nested elements. Baseline 2023+ (Chrome 117, Safari 16, Firefox 71).
Incorrect — manually duplicating parent grid columns:
/* WRONG: Nested grid duplicates parent's column definition */
.parent-grid {
display: grid;
grid-template-columns: 200px 1fr 100px;
gap: 1rem;
}
.child-card {
display: grid;
/* Fragile: must manually match parent columns */
grid-template-columns: 200px 1fr 100px;
grid-column: 1 / -1;
}
/* If parent columns change, every child must be updated */Correct — subgrid inherits parent tracks automatically:
.parent-grid {
display: grid;
grid-template-columns: 200px 1fr 100px;
gap: 1rem;
}
.child-card {
display: grid;
grid-template-columns: subgrid;
grid-column: 1 / -1;
/* Automatically aligns to parent's 3-column track */
}Card layout with aligned sections (rows subgrid):
/* Cards with aligned titles, content, and actions */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
grid-auto-rows: auto;
gap: 1.5rem;
}
.card {
display: grid;
grid-template-rows: subgrid;
grid-row: span 3; /* title + content + actions */
}
.card-title { align-self: start; }
.card-content { align-self: start; }
.card-actions { align-self: end; }Two-dimensional subgrid (rows + columns):
.dashboard {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: auto 1fr auto;
gap: 1rem;
}
.dashboard-widget {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: span 2;
grid-row: span 2;
}Key rules:
- Use
subgridinstead of duplicating parent grid track definitions grid-row: span Nis required so the child occupies tracks to inherit- Subgrid works for columns, rows, or both simultaneously
- Combine with
auto-fit/minmax()on parent for responsive card grids - Subgrid inherits the parent's
gap— override withgapon the child if needed - Baseline 2023+: Chrome 117+, Safari 16+, Firefox 71+, Edge 117+
Configure PWA offline support, manifest, and background sync for native-like installability — HIGH
PWA Offline & Installability
Implement offline-first patterns, background sync, install prompts, and web app manifests for native-like PWA experiences.
Incorrect — no offline fallback or install handling:
// WRONG: App crashes when offline
// No service worker fallback, no install prompt handling
// Users get browser error page instead of offline experienceCorrect — offline status hook and install prompt:
// useOnlineStatus hook
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// Install prompt hook
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const handler = (e: BeforeInstallPromptEvent) => {
e.preventDefault();
setInstallPrompt(e);
};
window.addEventListener('beforeinstallprompt', handler as EventListener);
if (window.matchMedia('(display-mode: standalone)').matches) setIsInstalled(true);
return () => window.removeEventListener('beforeinstallprompt', handler as EventListener);
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
setInstallPrompt(null);
if (outcome === 'accepted') { setIsInstalled(true); return true; }
return false;
};
return { canInstall: !!installPrompt, isInstalled, promptInstall };
}Background sync for offline form submissions:
// sw.js
import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';
registerRoute(
/\/api\/forms/,
new NetworkOnly({
plugins: [
new BackgroundSyncPlugin('formQueue', {
maxRetentionTime: 24 * 60, // 24 hours
}),
],
}),
'POST'
);PWA checklist:
- Service worker registered with offline fallback
- Web App Manifest with icons (192px + 512px maskable)
- HTTPS enabled (required for service workers)
- Offline page/experience works gracefully
- Responsive design (meta viewport set)
- Fast First Contentful Paint (< 1.8s)
Key rules:
- Use
display: "standalone"in manifest for app-like experience - Include both 192px and 512px icons with
maskablepurpose - Use BackgroundSyncPlugin for offline form submissions
- Prompt install at a natural moment (not on page load)
- Test offline behavior in Chrome DevTools Application tab
Configure service workers with Workbox 7.x caching strategies to prevent stale content — HIGH
PWA Service Worker & Workbox
Configure service workers with Workbox 7.x for reliable caching, update management, and installability.
Incorrect — service worker without update strategy:
// WRONG: No skipWaiting means users stuck on old version
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) =>
cache.addAll(['/index.html', '/app.js', '/style.css'])
)
);
});
// Missing: skipWaiting, clientsClaim, no update promptCorrect — Workbox with proper caching strategies:
import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
// Static assets: CacheFirst (hashed filenames = safe to cache long)
registerRoute(
/\.(?:js|css|woff2)$/,
new CacheFirst({
cacheName: 'static-v1',
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 365 * 24 * 60 * 60 }),
],
})
);
// API calls: NetworkFirst (fresh data preferred, cached fallback)
registerRoute(
/\/api\//,
new NetworkFirst({
cacheName: 'api-cache',
networkTimeoutSeconds: 10,
plugins: [new CacheableResponsePlugin({ statuses: [0, 200] })],
})
);
// Avatars/images: StaleWhileRevalidate (show cached, update in background)
registerRoute(
/\/avatars\//,
new StaleWhileRevalidate({ cacheName: 'avatars' })
);VitePWA integration:
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{ urlPattern: /^https:\/\/api\./, handler: 'NetworkFirst' },
],
},
manifest: {
name: 'My PWA App',
short_name: 'MyPWA',
theme_color: '#4f46e5',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
],
},
}),
],
});Key rules:
- Use
generateSWfor simple apps,injectManifestfor custom service worker logic - Always enable
clientsClaimandskipWaitingfor immediate activation - CacheFirst for static assets, NetworkFirst for APIs, StaleWhileRevalidate for non-critical images
- Never cache POST responses or authentication tokens
- Include
navigateFallback: '/index.html'for SPA offline support
React Responsive Patterns — HIGH
React Responsive Patterns
Incorrect:
// Hardcoded breakpoints with window.innerWidth
function Card() {
const isMobile = window.innerWidth < 768;
return <div className={isMobile ? "flex-col" : "flex-row"}>...</div>;
}Correct:
// Container queries via Tailwind — no JS needed
function Card() {
return (
<div className="@container">
<div className="flex flex-col @md:flex-row">...</div>
</div>
);
}Responsive Component with Container Queries
import { cn } from '@/lib/utils';
interface CardProps {
title: string;
description: string;
image: string;
}
export function ResponsiveCard({ title, description, image }: CardProps) {
return (
<div className="@container">
<article className={cn(
"flex flex-col gap-4",
"@md:flex-row @md:gap-6" // Container query breakpoints
)}>
<img
src={image}
alt=""
className="w-full @md:w-48 aspect-video object-cover rounded-lg"
/>
<div className="flex flex-col gap-2">
<h3 className="text-[clamp(1rem,0.5rem+3cqi,1.5rem)] font-semibold">
{title}
</h3>
<p className="text-[clamp(0.875rem,0.5rem+2cqi,1rem)] text-muted-foreground">
{description}
</p>
</div>
</article>
</div>
);
}Tailwind CSS Container Queries
// @container enables container query variants (@sm, @md, @lg, etc.)
<div className="@container">
<div className="flex flex-col @lg:flex-row @xl:gap-8">
<div className="@sm:p-4 @md:p-6 @lg:p-8">
Content adapts to container
</div>
</div>
</div>useContainerQuery Hook
import { useRef, useState, useEffect } from 'react';
function useContainerQuery(breakpoint: number) {
const ref = useRef<HTMLDivElement>(null);
const [isAbove, setIsAbove] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(([entry]) => {
setIsAbove(entry.contentRect.width >= breakpoint);
});
observer.observe(element);
return () => observer.disconnect();
}, [breakpoint]);
return [ref, isAbove] as const;
}
// Usage
function AdaptiveCard() {
const [containerRef, isWide] = useContainerQuery(400);
return (
<div ref={containerRef}>
{isWide ? <HorizontalLayout /> : <VerticalLayout />}
</div>
);
}Responsive Images Pattern
function ResponsiveImage({
src,
alt,
sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
}: {
src: string;
alt: string;
sizes?: string;
}) {
return (
<picture>
{/* Art direction with different crops */}
<source
media="(max-width: 640px)"
srcSet={`${src}?w=640&aspect=1:1`}
/>
<source
media="(max-width: 1024px)"
srcSet={`${src}?w=800&aspect=4:3`}
/>
<img
src={`${src}?w=1200`}
alt={alt}
sizes={sizes}
loading="lazy"
decoding="async"
className="w-full h-auto object-cover"
/>
</picture>
);
}Support foldable and multi-screen devices with safe area insets and viewport segment queries — MEDIUM
Foldable & Multi-Screen Devices
Use env(safe-area-inset-*) for notches/cutouts and viewport segment media queries for foldable-aware layouts.
Incorrect — ignoring safe areas and foldable considerations:
/* WRONG: Content hidden behind notch or fold hinge */
.app-header {
padding: 1rem;
/* No safe area consideration — text hidden behind notch */
}
.main-content {
display: flex;
/* No awareness of fold seam — content split across hinge */
}Correct — safe area insets for notches and cutouts:
/* Respect device safe areas (notch, rounded corners, home indicator) */
.app-header {
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
.app-footer {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
/* Required: viewport-fit=cover in meta tag */
/* <meta name="viewport" content="..., viewport-fit=cover"> */Foldable dual-screen layouts with viewport segments:
/* Detect dual-screen horizontal fold (e.g., Surface Duo landscape) */
@media (horizontal-viewport-segments: 2) {
.app-layout {
display: grid;
grid-template-columns:
env(viewport-segment-width 0 0)
calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0))
env(viewport-segment-width 1 0);
}
.panel-left { grid-column: 1; }
.fold-gap { grid-column: 2; } /* Hinge area — keep empty */
.panel-right { grid-column: 3; }
}
/* Detect dual-screen vertical fold (e.g., Surface Duo portrait) */
@media (vertical-viewport-segments: 2) {
.app-layout {
display: grid;
grid-template-rows:
env(viewport-segment-height 0 0)
calc(env(viewport-segment-top 0 1) - env(viewport-segment-bottom 0 0))
env(viewport-segment-height 0 1);
}
.panel-top { grid-row: 1; }
.fold-gap { grid-row: 2; }
.panel-bottom { grid-row: 3; }
}Progressive enhancement for foldable support:
/* Base layout — works on all devices */
.app-layout {
display: flex;
flex-direction: column;
}
/* Enhanced: dual-screen aware only when supported */
@media (horizontal-viewport-segments: 2) {
.app-layout {
display: grid;
/* ... dual-screen grid ... */
}
}Testing foldable layouts:
Chrome DevTools → Device toolbar → Select "Surface Duo" or "Galaxy Fold"
- Toggle fold posture (continuous / folded)
- Test both landscape and portrait orientations
- Verify content avoids the hinge/fold seam areaKey rules:
- Always use
env(safe-area-inset-*)withmax()to handle notches and cutouts - Add
viewport-fit=coverto the viewport meta tag to enable safe area insets - Use
@media (horizontal-viewport-segments: 2)for side-by-side foldable layouts - Use
@media (vertical-viewport-segments: 2)for top-bottom foldable layouts - Keep the hinge/fold area empty — never place interactive content on the seam
- Test on Chrome DevTools foldable emulators (Surface Duo, Galaxy Fold)
- Use progressive enhancement — foldable styles layer on top of base layout
Touch target sizing, thumb zones, and mobile interaction principles (Fitts's Law applied) — HIGH
Touch Interaction Principles
Apply Fitts's Law and thumb-zone ergonomics to make mobile interfaces fast, reachable, and accessible.
Incorrect — blocked zoom and undersized targets:
<!-- WRONG: user-scalable=no and maximum-scale=1 are both WCAG violations -->
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">/* WRONG: 24px icon with no padding — too small to tap reliably */
.icon-btn { width: 24px; height: 24px; }Correct — proper viewport and 44px minimum tap areas:
<!-- CORRECT: Never restrict zoom — users with low vision need it -->
<meta name="viewport" content="width=device-width, initial-scale=1">/* CORRECT: visual icon 24px, tappable area expanded to 44px via padding */
.icon-btn {
width: 24px;
height: 24px;
padding: 10px; /* (24 + 20) = 44px tappable */
}
/* Primary buttons: 44px min (iOS HIG) / 48dp min (Material Design) */
.btn { min-height: 44px; padding: 0.75rem 1rem; }
/* WCAG 2.5.8 AA: 24x24px allowed only with 24px spacing buffer to nearest target */
.secondary-action { min-height: 24px; min-width: 24px; margin: 12px; }Thumb zone — bottom of screen is easy reach; top is hard:
// PRIMARY actions → bottom 1/3 (easy reach)
// SECONDARY actions → middle 1/3 (comfortable)
// STATUS / rarely-used → top 1/3 (hard reach)
// Bottom tab bar preferred over top nav on mobile
// FAB: bottom-right corner for right-handed majority
function BottomNav() {
return (
// Tailwind: fixed bottom bar respecting home indicator safe area
<nav className="fixed bottom-0 inset-x-0 flex bg-background border-t border-border"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<NavItem href="/" icon={<HomeIcon />} label="Home" />
<NavItem href="/search" icon={<SearchIcon />} label="Search" />
</nav>
)
}
function FAB({ onClick }: { onClick: () => void }) {
return (
<button onClick={onClick}
className="fixed bottom-6 right-6 w-14 h-14 rounded-full bg-primary text-primary-foreground shadow-lg"
style={{ bottom: 'calc(1.5rem + env(safe-area-inset-bottom))' }}>
<PlusIcon className="w-6 h-6" aria-hidden />
<span className="sr-only">Create new</span>
</button>
)
}Safe areas and mobile-first breakpoints:
/* Landscape: side cutouts require left/right insets */
.content-area {
padding-left: max(1rem, env(safe-area-inset-left));
padding-right: max(1rem, env(safe-area-inset-right));
}
/* Mobile-first: default styles are mobile, scale up with min-width */
/* 640px (sm) → 768px (md) → 1024px (lg) → 1280px (xl) → 1536px (2xl) */
.card { display: flex; flex-direction: column; }
@media (min-width: 1024px) { .card { flex-direction: row; } }Gestures — enhance, never require:
// CORRECT: swipe is optional; close button is always present
function BottomSheet({ onClose }: { onClose: () => void }) {
return (
<div role="dialog" aria-modal="true">
<button onClick={onClose} className="min-h-11 min-w-11" aria-label="Close">
<XIcon className="w-5 h-5" />
</button>
{/* Swipe-to-dismiss is progressive enhancement, not the only path */}
</div>
)
}Key rules:
- Minimum touch target: 44x44px (iOS) / 48x48dp (Material) for all primary interactions
- WCAG 2.5.8 minimum: 24x24px when spacing buffer of 24px separates it from adjacent targets
- Icon buttons smaller than 44px must use
paddingor a pseudo-element to expand the tap area user-scalable=noandmaximum-scale=1in viewport meta are WCAG accessibility violations — never use them- Bottom 1/3 of screen = easy reach — primary actions and navigation belong here
- Prefer bottom tab bars over top navigation on mobile; FAB goes bottom-right
- Always pair swipe/long-press gestures with a visible button alternative
- Use
env(safe-area-inset-bottom)on bottom nav and FABs to clear the home indicator - Use
env(safe-area-inset-left/right)in landscape to avoid side cutouts - Never override native pinch-to-zoom behavior in CSS or JS
References (2)
Container Queries
Container Queries Reference
Container Types
/* Size queries (width/height) */
container-type: inline-size; /* Query inline dimension */
container-type: size; /* Query both dimensions */
container-type: normal; /* No containment (default) */
/* Named container */
container-name: card;
/* Shorthand */
container: card / inline-size;Query Syntax
/* Width queries */
@container (min-width: 400px) { }
@container (max-width: 399px) { }
@container (width > 400px) { }
@container (400px <= width <= 800px) { }
/* Named container queries */
@container card (min-width: 400px) { }
/* Logical properties */
@container (min-inline-size: 400px) { }
@container (min-block-size: 300px) { }Container Query Units
/* Inline dimension (usually width) */
cqi /* 1% of container inline size */
/* Block dimension (usually height) */
cqb /* 1% of container block size */
/* Min/max of cqi and cqb */
cqmin
cqmax
/* Legacy (avoid - not logical) */
cqw /* Container width */
cqh /* Container height */Tailwind CSS v4 Integration
Container queries are built into Tailwind CSS v4 - no plugin required.
<!-- Enable container with @container -->
<div class="@container">
<div class="flex flex-col @md:flex-row @lg:gap-8">
<!-- Responsive to container, not viewport -->
</div>
</div>
<!-- Named containers -->
<div class="@container/card">
<div class="@lg/card:grid-cols-2">
<!-- Queries the 'card' container -->
</div>
</div>/* app.css - Tailwind v4 CSS-first approach */
@import "tailwindcss";
@theme {
/* Custom container breakpoints (optional) */
--container-3xs: 16rem;
--container-2xs: 18rem;
--container-xs: 20rem;
/* Default breakpoints: @sm (20rem), @md (28rem), @lg (32rem), etc. */
}Best Practices
/* ✅ Use logical units */
.card-title {
font-size: clamp(1rem, 5cqi, 2rem);
padding: 2cqi;
}
/* ✅ Nest containers carefully */
.outer {
container: outer / inline-size;
}
.inner {
container: inner / inline-size;
}
@container inner (min-width: 200px) { }
/* ❌ Don't query non-container */
.no-container {
/* No container-type set */
}
@container (min-width: 400px) {
/* This won't work! */
}Feature Detection
@supports (container-type: inline-size) {
.card-container {
container-type: inline-size;
}
}Fluid Typography
Fluid Typography Reference
The clamp() Formula
/* Syntax: clamp(min, preferred, max) */
font-size: clamp(1rem, 0.5rem + 2vw, 2rem);
/* Breakdown:
min: 1rem (16px at default)
preferred: 0.5rem + 2vw (scales with viewport)
max: 2rem (32px at default)
*/Accessible Fluid Scale
:root {
/* Always include rem to respect user preferences */
--text-xs: clamp(0.75rem, 0.7rem + 0.25vw, 0.875rem);
--text-sm: clamp(0.875rem, 0.8rem + 0.375vw, 1rem);
--text-base: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
--text-lg: clamp(1.125rem, 1rem + 0.625vw, 1.25rem);
--text-xl: clamp(1.25rem, 1rem + 1.25vw, 1.75rem);
--text-2xl: clamp(1.5rem, 1rem + 2.5vw, 2.5rem);
--text-3xl: clamp(1.875rem, 1rem + 4.375vw, 3.5rem);
--text-4xl: clamp(2.25rem, 1rem + 6.25vw, 4.5rem);
}
h1 { font-size: var(--text-4xl); }
h2 { font-size: var(--text-3xl); }
h3 { font-size: var(--text-2xl); }
h4 { font-size: var(--text-xl); }
p { font-size: var(--text-base); }
small { font-size: var(--text-sm); }Container-Based Fluid Type
/* For component-scoped scaling */
.card {
container-type: inline-size;
}
.card-title {
/* Scales with card width, not viewport */
font-size: clamp(1rem, 0.5rem + 5cqi, 1.75rem);
}
.card-body {
font-size: clamp(0.875rem, 0.5rem + 3cqi, 1rem);
}Calculating Fluid Values
Target: 16px at 320px viewport → 24px at 1200px viewport
Formula:
preferred = min + (max - min) × (viewport - min-viewport) / (max-viewport - min-viewport)
Step 1: Convert to vw
(24 - 16) / (1200 - 320) = 8 / 880 = 0.909% per px
0.909 × 100 = 0.909vw
Step 2: Calculate rem offset
At 320px: 16px = 1rem
16 - (320 × 0.00909) = 16 - 2.91 = 13.09px ≈ 0.818rem
Result:
font-size: clamp(1rem, 0.818rem + 0.909vw, 1.5rem);Accessibility Considerations
/* ❌ WRONG: Ignores user font preferences */
font-size: 5vw;
/* ❌ WRONG: Completely overrides user settings */
font-size: 16px;
/* ✅ CORRECT: Respects user preferences while scaling */
font-size: clamp(1rem, 0.5rem + 2vw, 2rem);
/* The rem portion ensures user's font-size preference
is always a factor in the final size */Line Height Scaling
/* Tighter line-height for larger text */
h1 {
font-size: clamp(2rem, 1rem + 5vw, 4rem);
line-height: clamp(1.1, 1.4 - 0.2vw, 1.3);
}
p {
font-size: clamp(1rem, 0.9rem + 0.5vw, 1.125rem);
line-height: 1.6; /* Static is fine for body */
}Tools
- Utopia.fyi - Fluid type scale generator
- Fluid Type Scale - Calculate clamp values
Remember
Stores decisions and patterns in knowledge graph. Use when saving patterns, remembering outcomes, or recording decisions.
Review Pr
PR review with parallel specialized agents. Use when reviewing pull requests or code.
Last updated on