
Responsive Craft
- 284 installs
- 56 repo stars
- Updated April 4, 2026
- kylezantos/responsive-craft
Ship layouts that adapt cleanly from phone to desktop—breakpoints, fluid grids, touch targets, and component reflow—without responsive regressions late in QA.
About
responsive-craft from kylezantos/responsive-craft guides agents to build polished responsive web UIs with deliberate breakpoints, fluid grids, and touch-safe components. It reduces layout breakage across phones, tablets, and desktops by encoding craft-level frontend decisions during implementation instead of reactive CSS fixes before ship.
- Breakpoint strategy
- Fluid layout patterns
- Touch-friendly components
- Cross-viewport QA habits
- Mobile-first reflow
Responsive Craft by the numbers
- 284 all-time installs (skills.sh)
- +41 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #843 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kylezantos/responsive-craft --skill responsive-craftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 284 |
|---|---|
| repo stars | ★ 56 |
| Last updated | April 4, 2026 |
| Repository | kylezantos/responsive-craft ↗ |
What it does
Ship layouts that adapt cleanly from phone to desktop—breakpoints, fluid grids, touch targets, and component reflow—without responsive regressions late in QA.
Files
Responsive Craft
Implement responsive design that works across all viewports — compensating for the lack of a visual canvas by making deliberate decisions upfront.
Quick Start
Transform an existing site: /responsive-craft audit or "make this responsive" or "fix the mobile layout" Build responsive from scratch: /responsive-craft build or "build this mobile-first" or "create a responsive layout" Preview all breakpoints: /responsive-craft preview or "show me the responsive preview" or "open the breakpoint preview"
---
Core Principles
1. Escalation model — Intrinsic CSS first (auto-fit, flex-wrap, clamp()) → container queries next (component-level) → media queries last (page-level only). If a simpler layer solves it, stop there.
2. Describe before you code — Without a canvas, explicitly describe responsive behavior before writing CSS. In Adaptive mode, use inline behavior notes (CSS comments). In Guided mode, write formal behavior specs (tables per component). Both catch design decisions a canvas would reveal passively.
3. Fluid by default, breakpoints by exception — Use clamp() for typography, spacing, sizing. Reserve hard breakpoints for structural changes: nav transforms, column count shifts, sidebar visibility.
4. Component containment — Components respond to their container, not the viewport. Use container queries. A card in a sidebar and a card in a full-width section should use the same CSS.
5. Test by dragging, not jumping — Slowly resize from 280px to 2560px in DevTools. Don't just check named breakpoints. This catches in-between failures.
6. Sticky/scroll needs explicit patterns — Sticky coordination, z-index stacking contexts, overflow ancestors, safe areas, virtual keyboards. These break silently. Use the patterns in references/sticky-scroll-patterns.md, don't improvise.
7. Recognize design forks, don't default silently — When a responsive translation has multiple valid approaches, present 2-3 options with tradeoffs and ask the user to choose. See references/responsive-design-forks.md.
---
The Three-Layer Responsive System
| Layer | Tool | Handles |
|---|---|---|
| Continuous | clamp(), fluid tokens, cqi units | Smooth scaling — font size, padding, gap |
| Component | Container queries (@container) | Adapting to context — card layout, nav items |
| Structural | Media queries (@media) | Page-level shifts — grid columns, nav transform, sidebar |
Escalation Decision Tree
Does this need to change layout?
No → clamp() for sizing. Done.
Yes → Does it depend on CONTAINER size?
Yes → Container query
No → Does it depend on VIEWPORT?
Yes → Media query (page-level only)
No → :has() or intrinsic sizing (auto-fit, flex-wrap)---
Mode Selection
This skill operates in three modes. Detect from $ARGUMENTS or ask.
Detection
$ARGUMENTScontains "preview", "show breakpoints", "live preview" → Preview$ARGUMENTScontains "audit", "transform", "fix", "improve", "retrofit" → Transform Existing$ARGUMENTScontains "build", "create", "new", "from scratch" → Build Responsive- User is working in an existing codebase with responsive issues → Transform Existing
- User is starting a new page/component → Build Responsive
- Ambiguous → Ask
If AskUserQuestion is available:
- Transform existing — Audit and improve responsive behavior of current code
- Build from scratch — Design responsive layout from the start
- Preview — Launch a live multi-breakpoint preview in the browser
Otherwise: "Are you transforming an existing site's responsive design, building something new, or just previewing?"
Interactivity Level
Skip for Preview mode — go straight to routing.
After mode selection, determine interactivity:
If AskUserQuestion is available:
- Adaptive — Moves fast. 1-2 discovery questions, then starts working. Surfaces design forks inline as they arise. No formal specs — decisions are made in the moment.
- Guided — Produces deliverables. Full discovery, writes behavior specs per component before coding, gets explicit approval before each stage. Best for complex layouts or when the user wants a spec to reference later.
Otherwise: "Do you want (1) Adaptive — fast, I'll ask as I go, or (2) Guided — I'll write behavior specs per component and get your approval before coding?"
Default to Adaptive if the user doesn't express a preference.
When to recommend Guided: If the layout has 5+ distinct responsive components, multiple sticky elements, or a dashboard-style layout, suggest Guided — the behavior specs prevent expensive rework later.
---
Routing
After mode and interactivity are selected:
| Mode | Read workflow | Load immediately |
|---|---|---|
| Preview | workflows/preview.md | None |
| Transform Existing | workflows/transform-existing.md | references/ai-failure-patterns.md |
| Build Responsive | workflows/build-responsive.md | references/modern-css-patterns.md, references/ai-failure-patterns.md |
Load other references on demand:
references/sticky-scroll-patterns.md— when sticky, scroll-snap, or independent scroll regions are involvedreferences/responsive-design-forks.md— when an ambiguous responsive translation is detectedreferences/testing-checklist.md— during verification stepreferences/modern-css-patterns.md— during Transform mode when implementing fixes
---
Gotchas — Where Claude Fails at Responsive Design
These are the most common mistakes. Check every responsive output against this list.
1. `100vh` on mobile — Use svh/dvh with vh fallback. 100vh overflows behind mobile browser chrome.
2. Desktop-first media queries — Always use min-width (mobile-first), not max-width. Mobile loads fewer overrides.
3. Missing `min-width: 0` on flex children — Default flex min-width is auto (content size). Long text/images overflow. Add min-width: 0 when content is dynamic.
4. `overflow: hidden` kills sticky — Any ancestor with overflow: hidden/scroll/auto breaks position: sticky. Use overflow: clip for visual clipping.
5. `transform` breaks `position: fixed` — Any transform on an ancestor makes fixed children position relative to that ancestor, not viewport.
6. iOS input zoom below 16px — Input font-size under 16px triggers Safari viewport zoom. Use font-size: max(16px, 1rem).
7. Missing safe area insets — Notched devices need env(safe-area-inset-*). Requires viewport-fit=cover in meta tag. Don't forget landscape orientation.
8. Z-index escalation — Values like 9999 signal misunderstanding of stacking contexts. Use isolation: isolate and a tiered z-index scale.
9. Missing `align-self: start` on sticky in flex/grid — Without this, the element stretches to full height and sticky has no room to stick. The #1 silent sticky failure.
10. Optimizing for one viewport — Code that looks perfect at 1440px breaks at 320px, 768px portrait, and ultrawide. Always test the full range.
For the complete list with code examples, see references/ai-failure-patterns.md.
---
Tools
This skill includes two CLI tools in scripts/ for visual responsive verification.
Live Multi-Viewport Preview
See all breakpoints simultaneously in the browser, with hot reload:
node ${CLAUDE_SKILL_DIR}/scripts/preview.js http://localhost:3000
node ${CLAUDE_SKILL_DIR}/scripts/preview.js ./index.html
node ${CLAUDE_SKILL_DIR}/scripts/preview.js http://localhost:3000 --breakpoints 375,768,1024,1440,1920Responsive Snapshots
Capture screenshots at every breakpoint. Supports before/after comparison:
# Capture current state
node ${CLAUDE_SKILL_DIR}/scripts/snapshot.js http://localhost:3000
# Capture baseline, make changes, then capture again for comparison
node ${CLAUDE_SKILL_DIR}/scripts/snapshot.js http://localhost:3000 --before
# ... make responsive changes ...
node ${CLAUDE_SKILL_DIR}/scripts/snapshot.js http://localhost:3000
# → generates comparison.html with before/after at each breakpointBoth tools require no dependencies — just Node.js. Snapshots require dev-browser for headless screenshots.
---
Reference Index
| File | Contents | Load when |
|---|---|---|
| modern-css-patterns.md | Container queries, clamp(), subgrid, :has(), viewport units, scroll-driven animations, nesting, @layer, logical properties | Writing or reviewing responsive CSS |
| sticky-scroll-patterns.md | Sticky coordination, scroll-snap, independent scroll regions, responsive data tables, modals/sheets, IntersectionObserver | Working with sticky, scroll, or complex layout patterns |
| responsive-design-forks.md | 8 ambiguous desktop→mobile translations with options and tradeoffs | When a responsive translation has no single right answer |
| ai-failure-patterns.md | 13 categories of AI responsive failures with bad/good code examples, pre-flight scan checklist | Pre-flight scan before outputting responsive code |
| testing-checklist.md | Priority viewports, 10-point check, edge cases, three-tier testing strategy, Playwright patterns | Verification step after implementation |
Workflow Index
| Workflow | Purpose |
|---|---|
| transform-existing.md | Audit → identify forks → fix responsive issues in priority order |
| build-responsive.md | Describe behavior → establish foundation → build mobile-first → verify |
| preview.md | Launch live multi-breakpoint preview in the browser |
Responsive Preview Tool
Problem Frame
When building responsive layouts in Claude Code, there's no way to see all breakpoints simultaneously. Developers either resize the browser manually, jump between DevTools presets, or check one viewport at a time. This is the core limitation that responsive-craft was built to compensate for with process (describe before you code, test by dragging).
A multi-viewport preview tool would close this gap directly — giving code-first developers the same "all breakpoints at once" view that Framer and Figma provide on their canvas.
User Flow
Developer working on responsive layout
│
├─── Live Preview ──────────────────────────────────────┐
│ Claude or user runs: `preview <url-or-file>` │
│ Opens browser with 4-5 iframes side by side │
│ Dev server hot reload propagates into iframes │
│ Developer sees all breakpoints updating live │
│ │
├─── Snapshot Capture ──────────────────────────────────┐
│ Claude or user runs: `snapshot <url> [--before]` │
│ Captures screenshots at each breakpoint │
│ Saves individual PNGs + tiled composite │
│ │
└─── Before/After Comparison ──────────────────────────┐
Run snapshot --before, make changes, run snapshot │
Generates side-by-side before/after at each width │Requirements
Live Multi-Viewport Preview
- R1. Single HTML page that displays the target URL in 4-5 side-by-side iframes at standard breakpoints (375px, 768px, 1024px, 1440px)
- R2. Each iframe is labeled with its viewport width and a descriptive name (e.g., "375px — Mobile")
- R3. Works with localhost dev servers (Vite, Next.js, etc.) — hot reload propagates into iframes automatically
- R4. Works with static HTML files via a lightweight local server (no framework required)
- R5. Breakpoints are configurable — user can adjust which widths are shown
- R6. Preview page itself is responsive — on a narrow monitor, iframes stack or allow horizontal scroll rather than breaking
Snapshot Capture
- R7. Capture full-page screenshots at each configured breakpoint using dev-browser (Playwright)
- R8. Save individual PNGs per breakpoint (e.g.,
home-375.png,home-768.png) - R9. Generate a tiled composite image with all breakpoints side by side
- R10. Support a
--beforeflag that saves snapshots as a baseline for later comparison
Before/After Comparison
- R11. After a
--beforebaseline exists, a subsequent snapshot generates a side-by-side diff view showing before and after at each breakpoint - R12. Comparison output is a single image or HTML page that can be shared or reviewed
Skill Integration
- R13. The responsive-craft skill can invoke the preview and snapshot tools during its workflows (both Transform and Build modes)
- R14. The tools are also usable standalone via CLI — no skill invocation required
- R15. Lives in a
scripts/directory within the responsive-craft skill initially, extractable to a standalone tool later
Success Criteria
- A developer can run one command and see their page at all major breakpoints simultaneously, live-updating as they edit
- Snapshot capture produces usable comparison artifacts without manual screenshotting
- Works with zero configuration for the common case (localhost dev server)
- Claude can invoke it during responsive-craft workflows to verify its own work
Scope Boundaries
- Not a full browser testing framework — no assertions, no CI integration
- Not trying to simulate real device behavior (touch events, safe areas, browser chrome) — that still needs real device testing
- No cross-browser comparison (Chrome only via Playwright is fine)
- No video recording — screenshots only for comparison mode
Key Decisions
- Start as skill scripts, extract later: Build inside responsive-craft's
scripts/directory first. If it proves broadly useful, extract to its own CLI package. - dev-browser for snapshots: Use the existing dev-browser (sandboxed Playwright) CLI for screenshot capture rather than raw Playwright, per Kyle's setup preferences.
- HTML + iframes for live preview: Simplest possible approach. No Electron, no custom browser. Just a web page that opens in your default browser.
Dependencies / Assumptions
- dev-browser CLI is available on the machine for snapshot features
- For static file preview, a lightweight HTTP server is needed (e.g.,
npx serveor a built-in Node script) - Hot reload in iframes relies on the dev server's existing HMR — no custom file watching needed for served apps
Outstanding Questions
Deferred to Planning
- [Affects R4][Technical] What's the lightest way to serve static files? Built-in Node HTTP server (~15 lines),
npx serve, or Python'shttp.server? - [Affects R9][Technical] Best approach for tiling screenshots into a composite? Sharp (Node), ImageMagick CLI, or an HTML page rendered to image via Playwright?
- [Affects R11][Needs research] For before/after diff views — simple side-by-side images, or pixel-diff highlighting? How much complexity is the diff worth?
- [Affects R5][Technical] How should configurable breakpoints be stored? JSON config file, CLI flags, or both?
- [Affects R13][Technical] How does the skill invoke the scripts? Direct
node scripts/preview.jscalls via${CLAUDE_SKILL_DIR}, or a shell wrapper?
Next Steps
/ce:plan for structured implementation planning
feat: Add Multi-Viewport Responsive Preview Tool
Overview
Add a live multi-viewport preview page and a screenshot snapshot tool to responsive-craft. This closes the core gap the skill was built to compensate for: the inability to see all breakpoints simultaneously when working in code.
Two capabilities: (1) an HTML preview page that displays a URL at 4-5 viewport widths side by side with live hot-reload, and (2) a snapshot script that captures screenshots at each breakpoint for before/after comparison.
Problem Frame
Code-first responsive development lacks a visual canvas. Developers resize manually, check one viewport at a time, or jump between DevTools presets. responsive-craft compensates with process (describe before you code, test by dragging). This tool compensates with tooling — giving the same "all breakpoints at once" view that Framer provides on its canvas. (see origin: docs/brainstorms/2026-04-02-responsive-preview-tool-requirements.md)
Requirements Trace
- R1. Single HTML page with 4-5 side-by-side iframes at standard breakpoints
- R2. Each iframe labeled with viewport width and descriptive name
- R3. Works with localhost dev servers (hot reload propagates into iframes)
- R4. Works with static HTML files via lightweight local server
- R5. Breakpoints configurable via CLI flags
- R6. Preview page itself is responsive (horizontal scroll on narrow monitors)
- R7. Capture full-page screenshots at each breakpoint using dev-browser
- R8. Save individual PNGs per breakpoint
- R9. Generate tiled composite image
- R10. Support
--beforeflag for baseline snapshots - R11. Before/after comparison as side-by-side HTML
- R12. Comparison output shareable as a single HTML file
- R13. Skill can invoke tools during workflows
- R14. Tools usable standalone via CLI
- R15. Lives in
scripts/directory within responsive-craft
Scope Boundaries
- Not a browser testing framework — no assertions, no CI integration
- Not simulating real device behavior (touch events, safe areas, browser chrome)
- Chrome only via dev-browser — no cross-browser comparison
- Screenshots only — no video recording
- No npm package or install step — pure Node.js scripts with zero dependencies
Context & Research
Relevant Patterns
- dev-browser CLI — Kyle's standard for browser automation. Sandboxed Playwright with AI-agent optimization. Available on the machine. Used for screenshot capture.
- `${CLAUDE_SKILL_DIR}` — Skill spec variable that resolves to the skill's directory. Used to reference scripts from within SKILL.md.
- iframe cross-origin — A local HTML page (
file://) or served page can iframehttp://localhost:*for display. The parent can't read iframe content (cross-origin), but doesn't need to — just displays it. Hot reload from Vite/Next.js propagates into iframes automatically.
External References
openCLI (macOS) — opens URLs/files in the default browser- Node.js
http.createServer— zero-dependency static file server - Playwright
page.screenshot({ fullPage: true })— captures full-page screenshots at any viewport width
Key Technical Decisions
- Zero dependencies: All scripts use only Node.js built-ins and dev-browser. No npm install, no package.json, no Sharp, no ImageMagick. This keeps the tool extractable and self-contained. (see origin: key decision "Start as skill scripts, extract later")
- HTML iframes for preview: Simplest possible approach. Each iframe gets a fixed width matching a breakpoint. The iframes are flex-wrapped in a container with horizontal scroll. No Electron, no custom browser shell. (see origin: key decision "HTML + iframes for live preview")
- dev-browser for snapshots: Consistent with Kyle's browser automation preferences. Provides Playwright's
setViewportSize+screenshotAPI. (see origin: key decision "dev-browser for snapshots") - Tiling via HTML-to-screenshot: To generate the composite image, render an HTML page that lays out the individual PNGs side by side, then screenshot that page with dev-browser. Avoids adding Sharp or ImageMagick as dependencies.
- Side-by-side HTML for comparison: Before/after diff is a static HTML page showing baseline and current screenshots at each breakpoint. More useful than a flat image — zoomable, shareable, scrollable. Pixel-diff highlighting deferred to later if demand exists.
- CLI flags for breakpoints: Default set baked in (375, 768, 1024, 1440). Override with
--breakpoints 320,768,1024,1440,1920. No config file for v1.
Open Questions
Resolved During Planning
- Static file serving approach: Built-in Node
http.createServerwithfs.readFile. ~20 lines, zero dependencies. MIME types for .html, .css, .js, .png, .jpg, .svg, .json cover all common cases. - Composite image generation: Render an HTML page containing
<img>tags for each screenshot, then capture that page with dev-browser. The HTML page itself can also serve as the shareable composite artifact (R12). - How skill invokes scripts:
node ${CLAUDE_SKILL_DIR}/scripts/preview.js <url>andnode ${CLAUDE_SKILL_DIR}/scripts/snapshot.js <url>. Documented in SKILL.md under a new "## Tools" section.
Deferred to Implementation
- Exact iframe sizing CSS: The preview page needs to handle the case where the monitor is narrower than the sum of all iframe widths. Horizontal scroll is the plan, but exact CSS (flex-wrap vs overflow-x vs grid) will be determined during implementation.
- dev-browser CLI exact invocation syntax: Need to confirm the exact command format for
dev-browser screenshotor equivalent. Will test during implementation. - Static server port selection: Use a random available port or a fixed default (e.g., 8787)? Will decide during implementation based on what's simplest.
Implementation Units
- [ ] Unit 1: Preview HTML page
Goal: Create the core multi-viewport preview page that displays any URL at multiple widths simultaneously.
Requirements: R1, R2, R3, R5, R6
Dependencies: None
Files:
- Create:
scripts/preview.html
Approach:
- Single self-contained HTML file (no external dependencies)
- Read target URL from query parameter:
preview.html?url=http://localhost:3000&breakpoints=375,768,1024,1440 - Default breakpoints baked in:
[375, 768, 1024, 1440] - For each breakpoint, create an iframe with that exact width, wrapped in a labeled container
- Labels show width + name (e.g., "375px — Mobile", "768px — Tablet", "1024px — Laptop", "1440px — Desktop")
- Outer container uses flexbox with
overflow-x: autofor horizontal scrolling when iframes exceed monitor width - Include a minimal toolbar: target URL display, refresh all button, breakpoint labels
- Inline all CSS and JS — no external files
Patterns to follow:
- Standard responsive iframe pattern: iframe
widthset explicitly,height: 100%of viewport minus toolbar - Query param parsing with
URLSearchParams
Test scenarios:
- Happy path: Open preview.html with
?url=http://localhost:3000— 4 iframes render at 375, 768, 1024, 1440px widths with correct labels - Happy path: Open with
?url=http://localhost:3000&breakpoints=320,768,1920— 3 iframes at specified custom widths - Edge case: Open with no
?urlparameter — shows a helpful message instead of blank iframes - Edge case: Open with one breakpoint (
?breakpoints=375) — renders a single iframe, no layout issues - Edge case: Window narrower than smallest iframe — horizontal scroll activates, no content clipping
Verification:
- Page opens in browser and displays multiple viewport frames simultaneously
- Modifying source files on a running Vite/Next.js dev server causes all iframes to hot-reload
---
- [ ] Unit 2: Static file server
Goal: Enable previewing static HTML files that aren't served by a framework dev server.
Requirements: R4
Dependencies: None (can be built in parallel with Unit 1)
Files:
- Create:
scripts/serve-static.js
Approach:
- Node.js
http.createServerwithfs.readFile - Serve files from a specified directory (defaults to current working directory)
- MIME type mapping for common web files:
.html,.css,.js,.json,.png,.jpg,.gif,.svg,.woff,.woff2 - Auto-select an available port (try 8787, increment if taken)
- Print the serving URL to stdout so the launcher can capture it
- Include a basic file-change watcher using
fs.watchthat injects a tiny live-reload script into served HTML files (a<script>tag that uses EventSource or polling to refresh on change)
Patterns to follow:
- Standard Node HTTP server pattern with
createServer+readFile - MIME type lookup via file extension map object
Test scenarios:
- Happy path: Run
node serve-static.js ./my-page/— serves files on a local port, accessible in browser - Happy path: Modify a served HTML file — browser reloads automatically
- Edge case: Port 8787 is occupied — server picks the next available port
- Edge case: Request for a file that doesn't exist — returns 404 with a useful message
- Error path: Directory path doesn't exist — exits with a clear error message
Verification:
- Static HTML file is accessible at
http://localhost:<port>/index.html - File changes trigger reload in the browser
---
- [ ] Unit 3: Preview launcher script
Goal: A single CLI entry point that opens the preview page in the default browser, starting the static server if needed.
Requirements: R4, R14
Dependencies: Unit 1, Unit 2
Files:
- Create:
scripts/preview.js
Approach:
- Accept CLI args:
node preview.js <url-or-path> [--breakpoints 375,768,1024,1440] - Detect whether the argument is a URL (
http://orhttps://) or a file path - If URL: open
preview.html?url=<encoded-url>&breakpoints=<list>in the default browser - If file path: start
serve-static.jstargeting that directory, capture the port, then open the preview page pointing at the served URL - The preview.html file itself is served by the static server (or opened as a local file if the target is already a URL)
- Use
child_process.exec('open <url>')on macOS to open the browser
Patterns to follow:
- CLI argument parsing with
process.argv.slice(2) child_process.execfor opening the browser
Test scenarios:
- Happy path:
node preview.js http://localhost:3000— opens browser with preview page showing 4 iframes - Happy path:
node preview.js ./index.html— starts static server, opens preview page pointing at served URL - Happy path:
node preview.js http://localhost:3000 --breakpoints 320,768,1920— custom breakpoints passed through - Edge case: File path with spaces — handled correctly
- Error path: Invalid file path — exits with error message
Verification:
- Running the script opens the multi-viewport preview in the default browser
- Both URL and file-path modes work without additional steps
---
- [ ] Unit 4: Snapshot capture script
Goal: Capture full-page screenshots at each breakpoint using dev-browser, save individual PNGs and a tiled composite.
Requirements: R7, R8, R9, R10, R14
Dependencies: None (independent of Units 1-3)
Files:
- Create:
scripts/snapshot.js
Approach:
- Accept CLI args:
node snapshot.js <url> [--breakpoints 375,768,1024,1440] [--before] [--output ./snapshots] - For each breakpoint: use dev-browser to navigate to the URL, set viewport width, capture a full-page screenshot
- Save individual PNGs as
<page>-<width>.png(e.g.,home-375.png) - If
--beforeflag: save to abefore/subdirectory instead of the main output directory - Generate a composite: create a temporary HTML page that lays out all PNGs side by side with labels, then screenshot that page at a width large enough to contain all images
- Save the composite HTML as a shareable artifact alongside the PNG composite
- Use
child_process.execSyncto invoke dev-browser commands
Patterns to follow:
- dev-browser CLI for Playwright operations
fs.mkdirSyncwith{ recursive: true }for output directories
Test scenarios:
- Happy path:
node snapshot.js http://localhost:3000— creates 4 PNGs + 1 composite in./snapshots/ - Happy path:
node snapshot.js http://localhost:3000 --before— saves to./snapshots/before/ - Happy path: Custom breakpoints — only captures at specified widths
- Edge case: Output directory doesn't exist — created automatically
- Edge case: URL is unreachable — exits with clear error (not a silent hang)
- Error path: dev-browser not installed — exits with helpful message explaining the dependency
Verification:
- Running the script produces individual PNGs at each breakpoint width
- Composite image/HTML contains all breakpoints tiled side by side with labels
---
- [ ] Unit 5: Before/after comparison generator
Goal: Generate a side-by-side comparison showing baseline vs current screenshots at each breakpoint.
Requirements: R10, R11, R12
Dependencies: Unit 4 (uses snapshot output)
Files:
- Modify:
scripts/snapshot.js(add comparison mode)
Approach:
- When
snapshot.jsruns without--beforeand abefore/directory exists, automatically generate a comparison - Create an HTML page that shows before/after pairs at each breakpoint: before image on top, after image on bottom (or side by side if viewport allows)
- Include CSS for a slider/toggle between before and after (CSS-only, no JS dependency)
- Save as
comparison.htmlin the output directory — self-contained, openable in any browser - Also generate a flat composite PNG of the comparison using the same HTML-to-screenshot technique from Unit 4
Patterns to follow:
- Self-contained HTML with inline CSS (same approach as preview.html)
Test scenarios:
- Happy path: Run
snapshot --before, make changes, runsnapshotagain — produces comparison.html showing before/after at each breakpoint - Edge case: No
before/directory exists — skip comparison generation, only produce current snapshots - Edge case: Breakpoints changed between before and after runs — only compare breakpoints that exist in both sets
- Edge case: Number of before screenshots doesn't match current — compare matching widths, note missing ones
Verification:
- comparison.html opens in a browser and shows before/after pairs at each breakpoint
- Comparison is visually clear enough to identify responsive layout changes
---
- [ ] Unit 6: Skill integration
Goal: Update responsive-craft SKILL.md and workflows to reference the preview and snapshot tools.
Requirements: R13
Dependencies: Units 1-5
Files:
- Modify:
SKILL.md - Modify:
workflows/build-responsive.md - Modify:
workflows/transform-existing.md
Approach:
- Add a
## Toolssection to SKILL.md documenting the two scripts and their usage - In build-responsive.md: add a note in the verification step suggesting
node ${CLAUDE_SKILL_DIR}/scripts/preview.jsfor live preview during development andnode ${CLAUDE_SKILL_DIR}/scripts/snapshot.jsfor automated verification - In transform-existing.md: add a note in the audit step suggesting snapshot capture for before/after comparison of responsive fixes
- Keep integration lightweight — the tools are optional enhancements, not required steps
Patterns to follow:
- Existing SKILL.md reference index pattern (table with "Load when" column)
${CLAUDE_SKILL_DIR}variable from the skill spec
Test scenarios:
Test expectation: none — this unit modifies markdown documentation only, no behavioral code.
Verification:
- SKILL.md documents both tools with usage examples
- Both workflows reference the tools at appropriate points
- Tool paths use
${CLAUDE_SKILL_DIR}for portability
System-Wide Impact
- Interaction graph: The preview page iframes interact with the user's dev server via HTTP. No callbacks or middleware involved. The snapshot script invokes dev-browser as a child process — no persistent connection.
- Error propagation: If the target URL is unreachable, iframes show their native error state (browser's "can't reach" page). The snapshot script should catch dev-browser failures and report them clearly.
- State lifecycle risks: The
before/snapshot directory is persistent state — if the user runs--beforemultiple times, later runs overwrite earlier baselines. This is the desired behavior (latest baseline wins). - Unchanged invariants: The existing skill (SKILL.md, workflows, references) continues to work identically. The tools are additive — no existing behavior changes.
Risks & Dependencies
| Risk | Mitigation |
|---|---|
| iframe cross-origin restrictions prevent loading localhost | Test early in Unit 1. If file:// -> http://localhost is blocked, serve preview.html via the static server too |
| dev-browser CLI syntax differs from expected | Confirm exact invocation in Unit 4 implementation. Fall back to raw Playwright if needed |
| Composite HTML-to-screenshot approach produces poor quality | If dev-browser can't screenshot its own generated HTML cleanly, fall back to listing individual PNGs without a composite |
Static file watcher (fs.watch) is unreliable on some platforms | Use polling fallback if fs.watch events are inconsistent. This is a known Node.js issue on macOS with some editors |
Sources & References
- Origin document: docs/brainstorms/2026-04-02-responsive-preview-tool-requirements.md
- dev-browser: Kyle's sandboxed Playwright CLI for browser automation
- Node.js
http.createServer: https://nodejs.org/api/http.html - Skill spec
${CLAUDE_SKILL_DIR}: https://code.claude.com/docs/en/skills
responsive-craft
A Claude Code skill for implementing responsive design across websites and web apps — from standard mobile-first layouts to complex patterns like sticky element coordination, independent scroll regions, responsive data tables, and dashboard layouts.
The Problem
When you're building responsive layouts in Claude Code (or any code-first environment), you don't have a visual canvas like Figma or Framer where you can see all breakpoints side by side. This means responsive design decisions get made reactively — you write code, preview, resize, and fix — instead of deliberately.
responsive-craft compensates for this by front-loading the right decisions: describing responsive behavior before writing CSS, using an escalation model that picks the simplest tool for each job, and surfacing design forks where there's no single correct answer.
What It Does
Three Modes
- Transform Existing (
/responsive-craft audit) — Audits your current codebase's responsive implementation, identifies issues and ambiguous translations, and fixes them in priority order. - Build From Scratch (
/responsive-craft build) — Helps you design responsive behavior before writing CSS, establishes a mobile-first foundation, and builds with the right CSS tool for each pattern. - Live Preview (
/responsive-craft preview) — Opens a multi-breakpoint preview in your browser with interactive iframes at 375px, 768px, 1024px, and 1440px, all pointing at your dev server. Scroll, click, and navigate each viewport independently. Also offered automatically after completing an audit or build.
Two Interactivity Levels
- Adaptive — Moves fast. Quick discovery, surfaces design decisions inline as they come up.
- Guided — Produces formal behavior specs per component before coding. Best for complex layouts.
Design Forks
The skill's key differentiator. When a responsive translation has multiple valid approaches — a sidebar that could become a drawer, tabs, or an accordion; a data table that could scroll, stack as cards, or hide columns — the skill presents 2-3 options with tradeoffs and asks you to choose instead of silently picking one.
8 fork patterns covered: sidebar content, data tables, multi-panel dashboards, complex heroes, multiple sticky elements, deep navigation, complex forms, and bento grids.
Core Principles
1. Escalation model — Intrinsic CSS first (auto-fit, flex-wrap, clamp()) → container queries → media queries last 2. Describe before you code — Behavior notes or specs per component before writing CSS 3. Fluid by default, breakpoints by exception — clamp() for continuous scaling; breakpoints only for structural changes 4. Component containment — Components respond to their container, not the viewport 5. Test by dragging, not jumping — Resize continuously from 280px to 2560px 6. Sticky/scroll needs explicit patterns — These break silently; use documented patterns 7. Recognize design forks — Don't default silently when there are multiple valid approaches
What's Inside
responsive-craft/
├── SKILL.md # Core principles, routing, gotchas
├── workflows/
│ ├── transform-existing.md # Audit → forks → fix existing sites
│ ├── build-responsive.md # Describe → foundation → build mobile-first
│ └── preview.md # Launch live multi-breakpoint preview
├── scripts/
│ ├── preview.js # Serves preview.html over HTTP, opens browser
│ ├── preview.html # Multi-viewport iframe UI (dark theme, toolbar, scale toggle)
│ ├── snapshot.js # Headless screenshots at every breakpoint
│ └── serve-static.js # Zero-dependency static server with live reload
├── references/
│ ├── modern-css-patterns.md # Container queries, clamp(), subgrid, :has(), viewport units, @layer
│ ├── sticky-scroll-patterns.md # Sticky coordination, scroll-snap, scroll regions, modals/sheets
│ ├── responsive-design-forks.md # 8 ambiguous patterns with options and tradeoffs
│ ├── ai-failure-patterns.md # 13 categories of where AI breaks responsive code
│ └── testing-checklist.md # Priority viewports, 10-point check, testing strategy
└── README.mdReference Highlights
- modern-css-patterns.md — The 2026 CSS responsive toolkit: container queries,
clamp()with Utopia-style fluid scales, subgrid,:has()for content-conditional layouts, modern viewport units (svh/dvh), scroll-driven animations, CSS nesting,@layer, logical properties. Includes Tailwind mappings.
- ai-failure-patterns.md — 13 specific, recurring mistakes AI makes with responsive CSS:
100vhon mobile, desktop-first queries, missingmin-width: 0on flex children,overflow: hiddenkilling sticky, iOS input zoom, z-index escalation, and more. Each with bad output, why it breaks, and the correct pattern. Includes a pre-flight scan checklist.
- sticky-scroll-patterns.md — Production patterns for the complex stuff: coordinating multiple sticky elements with CSS custom properties, debugging sticky failures, scroll-snap carousels, independent scroll regions (dashboard pattern), sticky + responsive transitions, IntersectionObserver for stuck detection.
Live Preview
The standout verification tool. When you can't drag-resize a browser in a code-first environment, this gives you the next best thing — all your key breakpoints rendered simultaneously in real, interactive iframes. No screenshots, no mocking — actual live pages you can scroll and click through.
Run /responsive-craft preview to launch it standalone, or it's offered automatically at the end of every audit and build workflow.
The preview tool:
- Serves everything over HTTP automatically (no cross-origin iframe issues)
- Works with any dev server (Vite, Next.js, etc.) or static HTML files
- Supports custom breakpoints via
--breakpointsflag - Cleans up after itself on exit
Install
One command (all agents)
npx skills add kylezantos/responsive-craftAuto-detects your installed agents and installs to each. Works with Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Windsurf, and 35+ more.
Target specific agents
npx skills add kylezantos/responsive-craft -a claude-code
npx skills add kylezantos/responsive-craft -a codex -a opencodeManual install
Copy the entire responsive-craft/ directory into your agent's skills path:
| Agent | Path |
|---|---|
| Claude Code | ~/.claude/skills/responsive-craft/ |
| Codex | ~/.codex/skills/responsive-craft/ |
| OpenCode | ~/.config/opencode/skills/responsive-craft/ |
| Cursor | ~/.cursor/skills/responsive-craft/ |
| Gemini CLI | ~/.gemini/skills/responsive-craft/ |
| Windsurf | ~/.codeium/windsurf/skills/responsive-craft/ |
Or clone:
git clone https://github.com/kylezantos/responsive-craft.git ~/.claude/skills/responsive-craftUsage
# Transform an existing site
/responsive-craft audit
# Build responsive from scratch
/responsive-craft build
# Open a live multi-breakpoint preview
/responsive-craft preview
# Claude also auto-detects when you're working on responsive layouts
"make this responsive"
"fix the mobile layout"
"the sidebar breaks on tablet"
"show me the responsive preview"Framework Support
The skill detects your CSS approach and adapts output:
- Tailwind CSS — Uses responsive modifiers,
@containervariants,@themetokens - Component libraries (MUI, Chakra, shadcn) — Works with the library's responsive system
- CSS-in-JS — Same patterns, expressed through the library's API
- Vanilla CSS — Patterns applied directly
Cross-Agent Compatibility
Degrades Gracefully — Core functionality works on any AI coding agent. Claude Code users get tappable mode selection via AskUserQuestion; other agents get plain-text fallbacks.
License
MIT
AI Responsive Failure Patterns
Specific, recurring mistakes that AI coding assistants make when generating responsive CSS. Each entry shows the bad output, why it breaks, and the correct pattern.
Scan AI-generated responsive code against this list before shipping.
---
1. 100vh on Mobile
/* AI generates this almost universally */
.hero { height: 100vh; }100vh is calculated against the largest viewport (browser UI collapsed). On load with address bar visible, content overflows.
/* Correct */
.hero {
height: 100vh; /* fallback */
height: 100svh; /* modern browsers */
}Use svh for ~90% of cases. Use dvh only for elements that must track the exact visible area (chat containers, modals).
---
2. Desktop-First Media Queries
/* AI trained on older code defaults to max-width */
.container { width: 1200px; display: flex; }
@media (max-width: 1024px) { .container { width: 100%; } }
@media (max-width: 768px) { .container { flex-direction: column; } }Mobile loads all desktop styles then overrides. At intermediate widths, rules fight.
/* Mobile-first with min-width */
.container {
padding: 1rem;
display: flex;
flex-direction: column;
}
@media (min-width: 768px) { .container { flex-direction: row; padding: 2rem; } }
@media (min-width: 1024px) { .container { max-width: 1200px; margin: 0 auto; } }---
3. Missing min-width: 0 on Flex Children
/* AI output — long text overflows */
.card { display: flex; }
.card__title { overflow-wrap: break-word; }Default flex item min-width is auto (content size), not 0. Long text won't shrink below its content width.
.card__title {
min-width: 0; /* allows flex item to shrink below content size */
overflow-wrap: break-word;
}This is invisible in DevTools unless content is dynamic (long usernames, URLs, translated text).
---
4. overflow: hidden Killing Sticky
/* AI adds overflow: hidden for visual clipping */
.page-wrapper { overflow: hidden; }
.sticky-header { position: sticky; top: 0; } /* silently broken */Any ancestor with overflow: hidden/scroll/auto creates a scroll container, intercepting sticky.
/* Use overflow: clip instead — clips without creating scroll container */
.page-wrapper { overflow: clip; }---
5. transform Breaking Fixed Positioning
/* AI adds transform for animation */
.animated-section { transform: translateY(0); transition: transform 0.3s; }
.sticky-cta { position: fixed; bottom: 2rem; } /* now fixed to .animated-section, not viewport */Any transform, filter, backdrop-filter, perspective, will-change: transform on an ancestor creates a new containing block for position: fixed children.
---
6. iOS Input Zoom (Below 16px)
/* AI generates this — triggers auto-zoom on iOS Safari */
input, textarea { font-size: 14px; }iOS Safari auto-zooms the viewport when focusing an input with font-size below 16px. The page stays zoomed after leaving the field.
input, textarea, select {
font-size: max(16px, 1rem);
}Don't suppress with maximum-scale=1 in viewport meta — that prevents user-initiated zoom, violating accessibility guidelines.
---
7. Missing Safe Area Insets
/* AI forgets notch/Dynamic Island/home indicator */
.fixed-header { position: fixed; top: 0; }
.bottom-nav { position: fixed; bottom: 0; }<!-- Required for env() to return non-zero values -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">.fixed-header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.bottom-nav {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}Landscape orientation: notch moves to the side. safe-area-inset-left/right become significant. AI almost never handles landscape.
---
8. Z-Index Escalation
/* AI generates escalating values */
.modal { z-index: 99999; }
.tooltip { z-index: 999999; }This signals misunderstanding of stacking contexts. Properties that silently create stacking contexts (trapping z-index): opacity < 1, any transform, filter, will-change, position: sticky/fixed, mix-blend-mode, clip-path, isolation: isolate.
/* Use isolation: isolate to deliberately contain stacking */
.modal { isolation: isolate; }
.modal-overlay { z-index: 1; }
.modal-content { z-index: 2; }---
9. Virtual Keyboard Displacing Fixed Elements
/* AI output — chat input disappears under keyboard */
.chat-input { position: fixed; bottom: 0; }When the mobile keyboard opens, the visual viewport shrinks. Fixed elements at bottom: 0 get pushed off-screen or overlap the keyboard.
// iOS-compatible approach
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', () => {
document.documentElement.style.setProperty(
'--keyboard-height',
`${window.innerHeight - window.visualViewport.height}px`
);
});
}.chat-input { position: fixed; bottom: var(--keyboard-height, 0); }---
10. display: none for Mobile/Desktop Variants
<!-- AI duplicates content -->
<nav class="desktop-nav">...</nav>
<nav class="mobile-nav">...</nav>Duplicate content: double the DOM, double the downloads. Screen readers may read both. Use one component that adapts:
.nav { display: flex; flex-direction: column; }
@media (min-width: 768px) { .nav { flex-direction: row; } }---
11. Images Without Dimensions (Layout Shift)
<!-- AI omits width/height — browser can't reserve space -->
<img src="product.jpg" alt="Product" class="w-full">Without explicit width/height, the browser can't compute aspect ratio before load. Content shifts when the image appears.
<img src="product.jpg" alt="Product" width="800" height="600" class="w-full h-auto">Don't use width: auto in CSS when HTML width/height attributes are set — it overrides the browser's aspect ratio calculation.
---
12. Missing align-self: start on Sticky in Flex/Grid
/* AI puts sticky on a grid/flex child without this */
.sidebar { position: sticky; top: 0; }In flex/grid containers, children stretch to fill their row by default. The sidebar becomes as tall as the content — sticky has no room to "stick."
.sidebar {
position: sticky;
top: 0;
align-self: start; /* required */
}---
13. Optimizing for One Viewport
AI generates code that looks perfect at 1440px and breaks everywhere else:
- At 320px: Fixed-width items overflow, absolutely positioned elements fall off-screen, padding consumes all space
- At 768px portrait: Two-column layouts look cramped; sidebar + main breaks awkwardly
- At in-between sizes (843px, 900px): Inherits desktop styles too early, content wasn't designed for that width
- At ultrawide (1920px+): No
max-widthon containers — lines become unreadably long (>80ch)
Defensive defaults:
/* Always cap content width */
.content {
max-width: min(1200px, calc(100% - 2rem));
margin-inline: auto;
}
/* Fluid values prevent in-between breakage */
h1 { font-size: clamp(1.5rem, 4vw, 3rem); }
.section { padding: clamp(1rem, 5vw, 4rem); }---
Quick Scan Checklist
When reviewing AI-generated responsive code, check for:
- [ ]
height: 100vh— replace withsvh/dvh+vhfallback - [ ]
max-widthmedia queries — indicates desktop-first - [ ] Fixed pixel widths without
max-widthcompanion - [ ]
flexchildren withoutmin-width: 0(when content could be dynamic) - [ ]
overflow: hiddenon any parent of sticky/fixed element - [ ]
position: fixedinsidetransformparent - [ ] Z-index values above 10 — probable stacking context confusion
- [ ] Input font-size below 16px — iOS zoom trigger
- [ ] Fixed bottom elements with no keyboard accommodation
- [ ]
env(safe-area-inset-*)usage — needsviewport-fit=covermeta tag - [ ]
display: nonefor mobile/desktop component variants - [ ] Images without
width/heightattributes - [ ] No
box-sizing: border-boxglobal reset - [ ] Missing
max-widthon content containers (ultrawide problem) - [ ]
flex-wrapmissing on containers that should wrap - [ ]
object-fitmissing on images in fixed-size containers
Modern CSS Responsive Patterns
The CSS responsive toolkit as of 2026. Organized by the three-layer model: fluid values, container-level, then viewport-level.
Framework Detection
Before applying patterns, check what CSS approach the project uses:
- Tailwind CSS — Use Tailwind's responsive modifiers,
@containervariants, and@themetokens. See the Tailwind mapping notes throughout this file. - CSS-in-JS (styled-components, Emotion) — Same patterns, expressed through the library's API (template literals, responsive props).
- Component library (MUI, Chakra, shadcn) — Use the library's responsive system (Chakra's responsive prop objects, MUI's
sxbreakpoint syntax). Don't fight it with raw media queries. - Vanilla CSS / CSS Modules — Apply patterns directly.
When the project uses Tailwind, output Tailwind classes, not raw CSS. Each section below includes Tailwind equivalents where relevant.
---
Layer 1: Fluid Values (No Breakpoints Needed)
clamp() for Typography and Spacing
clamp(min, preferred, max) scales continuously between two bounds. Eliminates most font-size and spacing breakpoints.
The formula (slope-intercept, used by Utopia):
slope = (maxSize - minSize) / (maxViewport - minViewport) [all in rem]
intercept = -(minViewport * slope) + minSize
Result: clamp(minSize, intercept + slope * 100vw, maxSize)Worked example — heading from 1.5rem at 360px to 3rem at 1200px:
/*
minVw = 360/16 = 22.5rem, maxVw = 1200/16 = 75rem
slope = (3 - 1.5) / (75 - 22.5) = 0.02857
intercept = -(22.5 * 0.02857) + 1.5 = 0.857
*/
font-size: clamp(1.5rem, 0.857rem + 2.857vw, 3rem);Utopia fluid type scale — generate a harmonious set at utopia.fyi:
:root {
--step--1: clamp(0.83rem, 0.78rem + 0.29vw, 1.00rem);
--step-0: clamp(1.00rem, 0.91rem + 0.43vw, 1.25rem);
--step-1: clamp(1.20rem, 1.07rem + 0.63vw, 1.56rem);
--step-2: clamp(1.44rem, 1.26rem + 0.89vw, 1.95rem);
--step-3: clamp(1.73rem, 1.48rem + 1.24vw, 2.44rem);
--step-4: clamp(2.07rem, 1.73rem + 1.70vw, 3.05rem);
}Fluid spacing — same approach, semantic names:
:root {
--space-s: clamp(0.75rem, 0.69rem + 0.29vw, 0.875rem);
--space-m: clamp(1rem, 0.93rem + 0.38vw, 1.25rem);
--space-l: clamp(1.5rem, 1.38rem + 0.57vw, 1.75rem);
--space-xl: clamp(2rem, 1.86rem + 0.71vw, 2.5rem);
--space-section: clamp(3rem, 8vw, 6rem);
}Accessibility rule: Always combine rem + vw in the preferred value — never pure vw. The rem component respects browser zoom; pure vw doesn't scale when users zoom to 200%. If max / min <= 2.5, it passes WCAG SC 1.4.4.
When NOT to use clamp():
- Button labels and UI chrome (should be consistent, not scaling)
- Text in containers where predictable wrapping matters
- Legal/compliance copy requiring fixed sizing
Intrinsic Sizing (No Queries at All)
Self-adjusting grid:
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
gap: var(--space-m);
}The min(280px, 100%) prevents overflow on viewports narrower than 280px.
`auto-fit` vs `auto-fill` — Claude frequently confuses these:
auto-fit— collapses empty tracks, stretching items to fill the row. Use when you want items to grow into available space.auto-fill— keeps empty tracks, leaving gaps at the end. Use when you want consistent item widths even with few items.
Most responsive grids want auto-fit. Use auto-fill only when items should maintain a fixed max width regardless of available space.
Tailwind: grid grid-cols-[repeat(auto-fit,minmax(min(280px,100%),1fr))] or use arbitrary grid values. For simpler grids: grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3.
Sidebar layout without breakpoints (Every Layout pattern):
.layout {
display: flex;
flex-wrap: wrap;
gap: var(--space-l);
}
.sidebar {
flex-basis: 20rem;
flex-grow: 1;
min-width: 0;
}
.main {
flex-basis: 0;
flex-grow: 999;
min-width: min(60%, 30rem);
}When container is too narrow for both, they wrap naturally.
---
Layer 2: Container Queries (Component-Level)
Core Syntax
/* Establish container */
.card-wrapper {
container-type: inline-size;
container-name: card;
/* shorthand: container: card / inline-size; */
}
/* Query it */
@container card (width > 400px) {
.card {
display: grid;
grid-template-columns: 200px 1fr;
}
}container-type Values
| Value | Enables | Use when |
|---|---|---|
inline-size | Width queries | Default choice — 95% of cases |
size | Width + height queries | Dashboard widgets with fixed dimensions |
normal | Style queries only | CSS custom property-based queries |
Avoid size unless you need height queries — it requires explicit height and can create layout loops.
Container Query Units
| Unit | Definition |
|---|---|
cqi | 1% of container's inline size (width in LTR) |
cqb | 1% of container's block size |
cqmin | Smaller of cqi or cqb |
cqmax | Larger of cqi or cqb |
Prefer cqi/cqb over cqw/cqh — they're writing-mode aware.
Fluid sizing inside containers:
.card__title {
font-size: clamp(1rem, 1.5cqi + 0.5rem, 1.75rem);
}Container Queries vs Media Queries
| Use case | Tool |
|---|---|
| Component layout (card, widget, nav item) | Container query |
| Page structure (grid columns, sidebar visibility) | Media query |
| Device orientation, user preferences, print | Media query |
| Reusable component in different contexts | Container query |
Tailwind container queries (built-in, no plugin):
<div class="@container">
<div class="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">...</div>
</div>Tailwind's @ breakpoints are smaller than viewport breakpoints (@md = 448px vs md = 768px). Use named containers for nested contexts: @container/sidebar → @sm/sidebar:hidden.
Critical Gotchas
A container cannot query itself. Only children respond to the container's size. You may need a wrapper element.
Container query units can't be used on the container element itself:
/* INVALID */
.card { container-type: inline-size; padding: 10cqi; }
/* VALID — on children */
.card > * { padding: 10cqi; }Custom properties don't work in container query conditions:
/* Does NOT work */
@container (min-width: var(--breakpoint)) { }Flex items that are also containers may collapse without explicit sizing. Set min-width: 0 or flex: 1.
Grid items: Don't make grid items containers directly — wrap them in a div.
Browser Support
Container size queries: Chrome 105+, Firefox 110+, Safari 16+. ~95% global coverage. Production-ready.
Style queries (@container style(--var: val)): Chrome/Edge only. Use as progressive enhancement.
---
Layer 3: Viewport Media Queries (Page-Level)
Still the right tool for:
- Global layout shifts (sidebar appears/disappears, column count changes)
prefers-color-scheme,prefers-reduced-motion,prefers-contrast- Print stylesheets
- Device orientation
- Input method detection
Tailwind viewport breakpoints: sm: (640px), md: (768px), lg: (1024px), xl: (1280px), 2xl: (1536px). All mobile-first (min-width). Range targeting: md:max-xl:flex. Arbitrary: min-[320px]:text-center.
Input Method Detection
@media (pointer: fine) { /* mouse/trackpad */ }
@media (pointer: coarse) { /* touch/stylus */ }
@media (hover: hover) { /* device supports hover */ }
@media (hover: none) { /* touch devices — never gate functionality on hover */ }Safe Areas (Notched Devices)
<!-- Required in <head> -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">.fixed-header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.bottom-nav {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}Landscape: the notch moves to the side — safe-area-inset-left/right become significant.
Responsive Images
Don't make mobile load a 2400px desktop hero image. Use srcset for resolution switching and <picture> for art direction.
Resolution switching (same image, different sizes):
<img
src="hero-800.jpg"
srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w, hero-2400.jpg 2400w"
sizes="(max-width: 600px) 100vw, (max-width: 1200px) 50vw, 1200px"
alt="Hero image"
width="2400"
height="1200"
loading="lazy"
>sizes tells the browser how wide the image will display at each viewport, so it picks the smallest srcset that covers that width at the device's pixel density.
Art direction (different crops per viewport):
<picture>
<source media="(max-width: 640px)" srcset="hero-mobile.jpg">
<source media="(max-width: 1024px)" srcset="hero-tablet.jpg">
<img src="hero-desktop.jpg" alt="Hero" width="2400" height="1200">
</picture>Use <picture> when the mobile image should be a different crop or composition — not just a smaller version.
Always include:
widthandheightattributes (prevents layout shift)loading="lazy"on below-fold imagesalttext
Tailwind: object-cover for images in fixed containers. No built-in srcset — use raw HTML attributes alongside Tailwind classes.
---
Modern Viewport Units
The 100vh Problem
Mobile browsers have dynamic UI (address bar). 100vh is calculated against the largest viewport (UI collapsed). On page load with address bar visible, content overflows.
The Three Families
| Unit | Sized with browser UI... | Use for |
|---|---|---|
svh | Fully expanded (smallest) | Default — hero sections, modals, anything that must fit on load |
lvh | Fully retracted (largest) | Backgrounds, decorative elements |
dvh | Dynamic — updates on scroll | Sparingly — chat interfaces, overlays that must fill exact space |
Always provide a `vh` fallback:
.hero {
height: 100vh; /* fallback */
height: 100svh; /* modern browsers */
}Avoid `dvh` for primary layout — it causes layout recalculation on scroll as the browser toolbar animates. Use svh for ~90% of cases.
Browser support: All three families baseline available since June 2025. ~95% coverage.
---
CSS Subgrid
Solves cross-component alignment in grids. Before subgrid, nested elements couldn't participate in the parent grid's tracks.
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-template-rows: auto 1fr auto; /* image, body, cta */
}
.card {
grid-row: span 3; /* span all row tracks */
display: grid;
grid-template-rows: subgrid; /* inherit parent's row tracks */
}Now card titles, bodies, and CTAs align across sibling cards regardless of content length.
Key gotcha: Must explicitly declare grid-row: span N where N matches the number of parent tracks. Without it, the card occupies one row and subgrid has no tracks to inherit.
Browser support: Chrome 117+, Firefox 71+, Safari 16+. ~97% coverage. Production-ready.
---
:has() for Content-Conditional Layouts
Style elements based on their descendants or state — without JavaScript.
/* Card with image gets horizontal layout */
.card:has(.card__image) {
grid-template-columns: 200px 1fr;
}
/* Layout adapts to sidebar presence */
.layout:has(.sidebar) {
grid-template-columns: 1fr 300px;
}
/* Form row highlights when input is invalid */
.form-row:has(input:invalid) { border-color: red; }:has() responds to content/state. Container queries respond to available space. They solve different problems and complement each other.
Browser support: Chrome 105+, Safari 15.4+, Firefox 121+. ~95% coverage.
---
Scroll-Driven Animations
Drive CSS animations with scroll position instead of time. No JavaScript scroll listeners.
Scroll progress — animation tied to scroll position:
.progress-bar {
animation: grow linear;
animation-timeline: scroll(root block);
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}View progress — animation tied to element visibility:
.section {
animation: fade-up linear both;
animation-timeline: view();
animation-range: entry 0% entry 40%;
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(2rem); }
to { opacity: 1; transform: translateY(0); }
}Only animate GPU-composited properties (transform, opacity, filter). Others cause jank.
Always gate behind reduced motion:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-timeline: scroll()) {
.element { animation: my-anim linear both; animation-timeline: view(); }
}
}Browser support: Chrome 116+, Edge 116+. Safari 18+ has partial support (behind flag). Firefox not yet. Progressive enhancement only — always provide a static fallback.
---
CSS Nesting
Co-locate responsive styles with their component:
.card {
padding: 0.5rem;
display: block;
@media (width >= 600px) {
padding: 1rem;
}
@container card (width > 400px) {
display: grid;
grid-template-columns: 200px 1fr;
}
&:hover { transform: translateY(-2px); }
}Cannot do BEM concatenation (unlike Sass): &__element does NOT create .card__element. Write the full class.
Browser support: Chrome 112+, Firefox 117+, Safari 17.2+. ~93% coverage.
---
@layer (Cascade Layers)
Declare explicit priority order for style groups:
@layer reset, base, layout, components, utilities;Later layers win over earlier ones. Unlayered styles always beat layered styles.
Keep responsive overrides in the same layer as the component:
@layer components {
.nav { flex-direction: column; }
@media (width >= 900px) {
.nav { flex-direction: row; }
}
}Browser support: Chrome 99+, Firefox 97+, Safari 15.4+. ~97% coverage.
---
Logical Properties
Layout in terms of content flow (inline/block) rather than physical direction (left/right/top/bottom). Automatically adapts to RTL and vertical writing modes.
| Physical | Logical |
|---|---|
width | inline-size |
height | block-size |
margin-left/right | margin-inline |
margin-top/bottom | margin-block |
padding-left/right | padding-inline |
top/bottom | inset-block |
left/right | inset-inline |
text-align: left | text-align: start |
.article {
max-inline-size: 65ch;
padding-inline: var(--space-m);
margin-inline: auto;
}
.pull-quote {
border-inline-start: 4px solid var(--accent);
padding-inline-start: 1rem;
}Fully supported in all browsers. Adoption pays off most on multilingual projects; for LTR-only it's future-proofing.
---
The Complete Decision Tree
Does this need to change layout?
No --> clamp() for sizing. Done.
Yes --> Does it depend on CONTAINER size?
Yes --> Container query
No --> Does it depend on VIEWPORT?
Yes --> Media query (page-level only)
No --> Does it depend on CONTENT/STATE?
Yes --> :has() or intrinsic sizing (auto-fit, flex-wrap)Responsive Design Forks
Patterns where there is no single correct responsive translation. When the skill identifies one of these, it should present the options with tradeoffs and ask the user to choose — not default silently.
---
How to Use This Reference
When auditing existing code or planning new responsive layouts, scan for the patterns below. Each fork includes:
- The desktop pattern — what exists or is planned
- Mobile options — 2-3 approaches with tradeoffs
- The question to ask — what the user needs to decide
- Signals — what in the codebase hints at which option is best
---
Fork 1: Sidebar with Mixed Content
Desktop pattern: Sidebar with navigation + filters + summary/stats + secondary actions.
Option A: Bottom Sheet Drawer
- Content stays grouped in a single drawer
- Triggered by a hamburger/menu button
- Good when sidebar content is secondary and rarely accessed
- Tradeoff: Hidden by default — users may not discover it
Option B: Tab Bar with Sections
- Each sidebar section becomes a tab or bottom nav item
- Content is accessible simultaneously without opening a drawer
- Good when sidebar sections are equally important
- Tradeoff: Takes permanent screen space; limited to 3-5 sections
Option C: Collapsible Accordion Inline
- Sidebar content collapses into an accordion above or below main content
- Everything is visible in the page flow
- Good when sidebar content is a reference that users scan
- Tradeoff: Lengthens the page; may push main content too far down
The Question
"This sidebar has [N] distinct content types: [list them]. On mobile, do you want them (a) hidden in a drawer and opened on demand, (b) accessible as permanent tabs, or (c) collapsed inline above the main content?"
Signals
- If sidebar has 5+ sections → drawer is likely best (too many for tabs)
- If sidebar has navigation → tabs or bottom nav
- If sidebar has filters → floating action button that opens a filter sheet
- If sidebar has stats/summary → inline above main content
---
Fork 2: Data Table with Many Columns
Desktop pattern: Table with 6+ columns of data.
Option A: Horizontal Scroll + Sticky First Column
- All data accessible by scrolling
- First column stays visible for context
- Good for data-heavy tables where all columns matter equally
- Tradeoff: Users may not realize they can scroll; discoverability is poor
Option B: Card Stack
- Each row becomes a card with label:value pairs
- Good for 4-8 columns where row-level reading matters more than column comparison
- Tradeoff: Cannot compare across rows; loses the tabular advantage
Option C: Priority Columns + Expand/Details
- Show only 2-3 essential columns; rest available via "expand" or details view
- Good when some columns are clearly more important than others
- Tradeoff: Requires deciding which columns are "essential" — may vary by user
The Question
"This table has [N] columns. Which columns are essential on mobile? That determines whether we (a) scroll horizontally with a sticky first column, (b) stack each row as a card, or (c) show priority columns with an expand option."
Signals
- If users compare across rows → horizontal scroll (A)
- If users read one row at a time → card stack (B)
- If there are clearly primary/secondary columns → priority + expand (C)
- If table has actions (edit/delete) per row → card stack handles this most cleanly
Implementation: Card Stack on Mobile
Mobile-first: the card layout is the base, table layout is added at wider viewports.
/* Mobile base: card layout */
table, thead, tbody, tr, th, td { display: block; }
thead tr { display: none; }
tr {
margin-bottom: 1rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1rem;
}
td {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
}
td::before {
content: attr(data-label);
font-weight: 600;
}
/* Desktop: restore table layout */
@media (min-width: 641px) {
table { display: table; }
thead { display: table-header-group; }
tbody { display: table-row-group; }
tr { display: table-row; margin: 0; border: none; padding: 0; }
th, td { display: table-cell; }
td::before { display: none; }
}Requires data-label attributes on each <td> in the HTML — Claude must add these when implementing. Note: changing display on table elements may break screen reader semantics — test with AT.
Implementation: Column Hiding
@media (min-width: 769px) { .col-secondary { display: table-cell; } }Only hide genuinely secondary data — hidden columns are invisible to assistive technology.
For sticky table header patterns (dual-axis sticky), see references/sticky-scroll-patterns.md.
---
Fork 3: Multi-Panel Dashboard
Desktop pattern: Header + sidebar nav + main content area + optional detail panel. Multiple information-dense regions visible simultaneously.
Option A: Tab-Based Single View
- Each panel becomes a tab or screen
- Cleanest mobile experience; one thing at a time
- Good for dashboards where users work in one section at a time
- Tradeoff: Loses the at-a-glance overview that makes dashboards valuable
Option B: Stacked with Anchored Navigation
- Panels stack vertically; sticky anchor nav at top for jumping between sections
- Everything is in the page flow, accessible by scrolling
- Good when all panels need to be scannable in sequence
- Tradeoff: Very long page; context switching requires scrolling
Option C: Collapsible Regions + Scroll
- Each panel collapses to a summary/header with expand capability
- Shows overview of all panels; user expands what they need
- Good when the summary is useful without the detail
- Tradeoff: Requires designing meaningful collapsed states
The Question
"This dashboard has [N] panels visible simultaneously on desktop. How much information density matters on mobile? (a) One panel at a time (tabs), (b) all panels stacked and scannable, or (c) collapsed summaries that expand on demand?"
Signals
- If dashboard is monitoring/status → collapsed summaries (C) — users scan for anomalies
- If dashboard is workflow → tabs (A) — users focus on one task
- If panels have sequential relationships → stacked (B)
---
Fork 4: Hero with Complex Visual Content
Desktop pattern: Large hero with background video/image, overlay text, CTA, secondary elements (stats, testimonials, animated graphics).
Option A: Simplified Adaptation
- Keep the same structure but simplify: static image replaces video, stacked layout, smaller text
- Good when the hero's message works at any size
- Tradeoff: May feel like a "shrunk desktop" — not designed for mobile
Option B: Different Mobile Hero
- Completely different mobile hero designed for the mobile context
- Smaller, faster, thumb-friendly
- Good when the desktop hero relies on visual impact that doesn't scale down
- Tradeoff: Two things to maintain; content can drift out of sync
Option C: Progressive Disclosure
- Mobile shows headline + CTA only; stats/testimonials/video are below the fold or behind a "learn more" interaction
- Good when the CTA is the primary goal and surrounding content is supporting
- Tradeoff: Supporting content may never be seen
The Question
"The desktop hero relies on [visual element] for impact that won't translate to 375px. Do you want to (a) adapt it (simpler version, same structure), (b) design a different mobile hero, or (c) reduce to headline + CTA with the rest below the fold?"
Signals
- If hero has background video → Option A with static image fallback, or C
- If hero has complex animations/graphics → Option B (they'll be janky on mobile)
- If the CTA is the whole point → Option C (strip everything else)
---
Fork 5: Multiple Sticky Elements
Desktop pattern: Sticky header (60px) + sticky subnav (40px) + sticky filters (48px). Total: ~148px of stuck content.
Option A: Keep All (Cascading Sticky)
- All three remain sticky with stacked
topoffsets - Good on large tablets and desktops
- Tradeoff: 148px is ~20% of a mobile screen. Content area becomes too small.
Option B: Collapse Subnav into Header
- Header absorbs subnav functionality (tabs, dropdown)
- Reduces sticky stack to ~70px
- Good when subnav items are few (3-5)
- Tradeoff: Header becomes more complex and may need its own responsive states
Option C: Filters Become Floating Button + Sheet
- Header stays sticky, subnav optionally sticky, filters open from a floating action button
- Reduces permanent sticky space to 60-100px
- Good when filters are used intermittently, not constantly
- Tradeoff: Filters are no longer visible; users may forget active filter state
The Question
"[N] sticky elements stack to ~[X]px on mobile — that's [Y]% of the screen. Which ones truly need to stay visible while scrolling?"
Signals
- If subnav has 3-5 items → collapse into header (B)
- If filters are applied rarely → floating button + sheet (C)
- If user constantly references all three → keep cascading but reduce heights (A with smaller mobile variants)
---
Fork 6: Complex Navigation
Desktop pattern: Mega menu, multi-level dropdowns, or navigation with 15+ items across 3+ categories.
Option A: Hamburger with Accordion Categories
- Single hamburger → full-screen overlay → category accordions
- The standard approach for deep navigation
- Tradeoff: Adds taps to reach any destination; deep nesting is confusing
Option B: Bottom Tab Bar + Category Pages
- Primary sections as bottom tabs; sub-navigation within each tab's page
- Good for app-like experiences with 3-5 top-level sections
- Tradeoff: Only works for 3-5 sections; more requires overflow handling
Option C: Search-First Navigation
- De-emphasize browse navigation; prominently feature search
- Good when users know what they're looking for
- Tradeoff: New users who don't know the content can't browse effectively
The Question
"This navigation has [N] items across [M] categories. On mobile, is the primary user behavior browsing/exploring or searching for something specific?"
Signals
- If content-heavy site with defined categories (e-commerce, docs) → hamburger + accordion (A)
- If app with 3-5 core flows (dashboard, messages, settings, profile) → bottom tabs (B)
- If content is highly searchable and users have intent (knowledge base, marketplace) → search-first (C)
- If navigation has 3+ nesting levels → hamburger (A) — bottom tabs can't handle depth
- If navigation items have icons → bottom tabs (B) — icon + label fits the tab bar pattern
---
Fork 7: Form Layout with Complex Fields
Desktop pattern: Multi-column form, inline validation, conditional fields, field groups side by side.
Option A: Single Column Stack
- Every field goes full-width, stacked vertically
- Simplest and most reliable on mobile
- Tradeoff: Long forms become very long; user may lose context of where they are
Option B: Stepped/Wizard
- Break form into steps; one group of fields per screen
- Good for forms with 10+ fields or natural groupings
- Tradeoff: Users can't scan the whole form; progress indicator needed
Option C: Accordion Sections
- Logical field groups collapse into sections; user expands one at a time
- Good for reference/settings forms where users edit specific sections
- Tradeoff: Users may miss required fields in collapsed sections
The Question
"This form has [N] fields in [M] groups. Is this a fill-once form (registration, checkout) or an edit-many-times form (settings, profile)?"
Signals
- Fill-once → stepped wizard (B) for 10+ fields, single column (A) for fewer
- Edit-many-times → accordion (C)
- Mix of required/optional fields → step through required, accordion for optional
---
Fork 8: Content Grid with Varying Card Types
Desktop pattern: Bento grid or masonry layout with hero cards, standard cards, and wide cards.
Option A: Linear Stack (All Same Width)
- Every card becomes full-width, stacked vertically
- Hero card can be taller or have a different layout to maintain hierarchy
- Tradeoff: Loses the visual rhythm and hierarchy of the bento grid
Option B: Horizontal Scroll Sections
- Group cards by type; each group scrolls horizontally
- Preserves some visual variety
- Tradeoff: Less discoverable; users may miss cards off-screen
Option C: Two-Column Mini Grid
- Standard cards go 2-up; hero card spans full width
- Preserves some grid feel on mobile
- Tradeoff: Cards may be too narrow on phones under 375px
The Question
"The desktop grid uses varying card sizes for visual hierarchy. On mobile, do you want to (a) stack everything full-width (simplest), (b) group into horizontal scroll sections (preserves variety), or (c) keep a 2-column mini-grid (preserves density)?"
Signals
- If hierarchy matters (featured vs. regular) → Option C with hero card full-width
- If all cards are equal → Option A
- If cards are grouped by category → Option B
Sticky & Scroll Patterns
Complex scroll-based layout patterns and their responsive considerations. These are where responsive design breaks hardest — they need explicit patterns, not intuition.
---
Multiple Sticky Elements
When a page has sticky header + sticky subnav + sticky sidebar, they need coordinated offsets and z-index management.
Stacking Multiple Sticky Elements
Use CSS custom properties so top offsets auto-update at breakpoints:
:root {
--header-height: 60px;
--subnav-height: 40px;
}
.site-header {
position: sticky;
top: 0;
z-index: 100;
height: var(--header-height);
}
.subnav {
position: sticky;
top: var(--header-height);
z-index: 90;
height: var(--subnav-height);
}
.table-header {
position: sticky;
top: calc(var(--header-height) + var(--subnav-height));
z-index: 80;
}
.sidebar {
position: sticky;
top: calc(var(--header-height) + 1rem);
align-self: start; /* CRITICAL — see below */
height: fit-content;
}Responsive breakpoint updates — mobile-first, change one variable, everything adjusts:
/* Mobile base values */
:root {
--header-height: 52px;
--subnav-height: 0px; /* subnav is a dropdown on mobile */
}
@media (min-width: 769px) {
:root {
--header-height: 60px;
--subnav-height: 40px;
}
}The align-self: start Rule
This is the single most missed sticky detail. In flex and grid containers, children stretch to fill their row by default. A sidebar that stretches to full height is already as tall as the content — sticky has no room to "stick" because the element never scrolls past the viewport.
/* Without this, sticky sidebar silently fails in flex/grid */
.sidebar {
position: sticky;
top: var(--header-height);
align-self: start; /* required */
}Z-Index Scale
Use a tiered scale — don't escalate arbitrarily:
:root {
--z-sticky: 100;
--z-subnav: 90;
--z-sidebar: 80;
--z-drawer: 200;
--z-modal: 300;
--z-toast: 400;
}---
Sticky Failures — Debugging Guide
position: sticky fails silently. When it doesn't work, check this list in order:
1. overflow on any ancestor — Any parent with overflow: hidden, overflow: scroll, or overflow: auto intercepts sticky. Use overflow: clip instead if you need visual clipping without creating a scroll container.
2. No defined height on scroll container — The parent must have height so there's room for the element to stick within.
3. No inset property — top, bottom, left, or right must be set to a non-auto value.
4. Element is as tall as its container — Happens in flex/grid without align-self: start. The element is already filling the space, so there's nothing to "stick."
5. Stacking context traps — transform, filter, opacity < 1, will-change, isolation: isolate on an ancestor creates a new stacking context. Sticky still works, but z-index is trapped within that context.
6. `transform` on parent breaks `position: fixed` children — Not sticky, but often confused with it. Any transform on an ancestor makes fixed children position relative to that ancestor, not the viewport.
---
Scroll-Snap Layouts
Horizontal Carousel with Snap
.carousel {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain; /* prevents triggering browser back gesture */
scrollbar-width: none;
}
.carousel-item {
flex: 0 0 85%; /* each item takes 85% of container */
scroll-snap-align: start;
scroll-snap-stop: always; /* prevents swiping through multiple */
}Full-Page Scroll-Snap
html {
scroll-snap-type: y mandatory;
}
section {
height: 100dvh; /* dvh, not vh, for mobile */
scroll-snap-align: start;
}Responsive Snap — Different Behavior Per Breakpoint
/* Mobile: horizontal scroll carousel */
.card-row {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
gap: 1rem;
padding: 0 1rem;
}
.card {
flex: 0 0 80vw;
scroll-snap-align: start;
}
/* Desktop: standard grid, no snap */
@media (min-width: 768px) {
.card-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
overflow-x: visible;
scroll-snap-type: none;
}
.card {
flex: none;
width: auto;
}
}Mandatory vs Proximity
mandatory— always snaps. Can trap users if snap points are far apart. Only use when items fit the viewport.proximity— snaps only when close to a snap point. Safer for variable-height content.
scroll-padding for Sticky Header Offset
.scroll-container {
scroll-snap-type: y mandatory;
scroll-padding-top: var(--header-height);
}---
Independent Scroll Regions
Dashboard layouts where sidebar and main content scroll independently.
The CSS Grid + Overflow Pattern
body {
display: grid;
grid-template-areas:
"header header"
"sidebar main";
grid-template-rows: var(--header-height) 1fr;
grid-template-columns: 260px 1fr;
height: 100dvh;
margin: 0;
overflow: hidden; /* all scrolling is within regions */
}
header { grid-area: header; }
.sidebar { grid-area: sidebar; overflow-y: auto; overscroll-behavior: contain; }
.main { grid-area: main; overflow-y: auto; overscroll-behavior: contain; }`overscroll-behavior: contain` is critical — without it, reaching the end of one scroll region chains to the parent (body scroll).
Responsive: Mobile-First (Single Column Base, Grid at Desktop)
Write the mobile layout as the base, add the grid at larger viewports:
/* Mobile base: single column, normal scroll */
body {
margin: 0;
}
.sidebar {
display: none; /* replaced by drawer/bottom-nav on mobile */
}
/* Desktop: multi-region dashboard */
@media (min-width: 769px) {
body {
display: grid;
grid-template-areas: "header header" "sidebar main";
grid-template-rows: var(--header-height) 1fr;
grid-template-columns: 260px 1fr;
height: 100dvh;
overflow: hidden;
}
.sidebar {
display: block;
overflow-y: auto;
overscroll-behavior: contain;
}
.main {
overflow-y: auto;
overscroll-behavior: contain;
}
}---
Sticky + Responsive Transitions
Elements that are sticky on desktop but flow normally on mobile.
Sidebar: Sticky on Desktop, Inline on Mobile
.toc {
position: relative; /* mobile: flows normally */
}
@media (min-width: 1024px) {
.layout {
display: grid;
grid-template-columns: 1fr 280px;
align-items: start;
}
.toc {
position: sticky;
top: calc(var(--header-height) + 1rem);
max-height: calc(100dvh - var(--header-height) - 2rem);
overflow-y: auto;
align-self: start;
}
}Header That Changes Height Across Breakpoints
:root { --header-height: 52px; }
@media (min-width: 768px) { :root { --header-height: 72px; } }
@media (min-width: 1200px) { :root { --header-height: 80px; } }
/* All elements that account for header height auto-update */
[id] { scroll-margin-top: calc(var(--header-height) + 1rem); }Sidebar Becomes Top Bar on Mobile
/* Mobile: horizontal tab bar */
.sidebar {
display: flex;
flex-direction: row;
overflow-x: auto;
position: sticky;
top: var(--header-height);
}
/* Desktop: vertical sidebar */
@media (min-width: 1024px) {
.sidebar {
display: block;
position: sticky;
top: calc(var(--header-height) + 1rem);
overflow-x: visible;
overflow-y: auto;
}
}---
scroll-margin and scroll-padding
For anchor links with sticky headers — prevents content from hiding behind the header.
On Target Elements
[id] {
scroll-margin-top: calc(var(--header-height) + 1rem);
}On the Scroll Container
html {
scroll-padding-top: calc(var(--header-height) + var(--subnav-height, 0px) + 1rem);
}Use `scroll-padding` when all anchors are in the same container with a consistent offset. Use `scroll-margin` when different elements need different offsets.
---
IntersectionObserver: Detecting Sticky "Stuck" State
CSS has no :stuck pseudo-class. Use a zero-height sentinel element:
<div class="sticky-sentinel" aria-hidden="true"></div>
<header class="site-header">...</header>.sticky-sentinel {
position: absolute;
height: 1px;
top: 0;
left: 0;
right: 0;
pointer-events: none;
}const sentinel = document.querySelector('.sticky-sentinel');
const header = document.querySelector('.site-header');
const observer = new IntersectionObserver(
([entry]) => {
header.classList.toggle('is-stuck', !entry.isIntersecting);
},
{ threshold: 0 }
);
observer.observe(sentinel);.site-header.is-stuck {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
}Always observer.disconnect() in component cleanup.
---
Sticky Table Headers (Dual-Axis Sticky)
When a data table needs both a sticky header row AND a sticky first column:
.table-wrapper {
overflow-x: auto;
overflow-y: auto;
max-height: 400px;
}
thead th {
position: sticky;
top: 0;
background: white;
z-index: 20;
}
.col-sticky {
position: sticky;
left: 0;
background: white;
z-index: 10;
}
/* The corner cell — highest z-index */
thead th.col-sticky {
z-index: 30;
}The sticky corner cell z-index is almost always forgotten. Without it, the header or column covers the corner when scrolling both directions.
Always wrap in role="region" with aria-label and tabindex="0" for keyboard scrolling.
For full responsive data table patterns (card layout, column hiding), see references/responsive-design-forks.md Fork 2.
---
Responsive Modals / Bottom Sheets
Desktop Modal to Mobile Bottom Sheet (Native <dialog>)
/* Mobile base: bottom sheet */
dialog {
border: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
margin: 0;
width: 100%;
border-radius: 20px 20px 0 0;
padding: 1.5rem;
padding-bottom: calc(1.5rem + env(safe-area-inset-bottom));
max-height: 85dvh;
overflow-y: auto;
}
/* Desktop: centered modal */
@media (min-width: 641px) {
dialog {
position: relative;
bottom: auto;
left: auto;
right: auto;
margin: auto;
max-width: 480px;
width: 90%;
border-radius: 12px;
padding: 2rem;
padding-bottom: 2rem;
max-height: none;
overflow-y: visible;
}
}Use showModal() — provides built-in focus trap, Esc to close, aria-modal="true".
Drawer Navigation
.drawer {
position: fixed;
top: 0;
left: 0;
height: 100dvh;
width: min(320px, 85vw);
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow-y: auto;
overscroll-behavior: contain;
}
.drawer.is-open { transform: translateX(0); }Responsive Testing Checklist
How to verify responsive design without a visual canvas. Organized from fastest to most thorough.
---
The Drag-Resize Habit
The single most valuable responsive testing technique: in Chrome DevTools device mode (Cmd+Shift+M), set to Responsive (not a preset device), then slowly drag the right handle from 280px to 2560px.
Don't jump between breakpoints — drag continuously. This catches:
- Content overflow at widths between breakpoints
- Text collision and wrapping issues
- Layout collapse at unexpected widths
- Elements that look fine at 768px but break at 900px
---
Media Query Visualization (Underused DevTools Feature)
In device toolbar: More Options > Show Media Queries. Chrome draws color-coded bars:
- Orange =
min-widthqueries (mobile-first) - Blue =
max-widthqueries (desktop-first) - Green = range queries
Click bars to jump to that breakpoint. Right-click to see the rule in source.
---
Priority Viewport Widths
Ranked by 2026 device market share and likelihood of catching issues:
| Priority | Width | Represents |
|---|---|---|
| 1 | 390px | iPhone 12-15 (most common iPhone) |
| 2 | 360px | Most common Android |
| 3 | 1920px | Dominant desktop (22% market share) |
| 4 | 768px | iPad / tablet breakpoint |
| 5 | 1366px | Second most common desktop |
| 6 | 375px | iPhone SE / older iPhones |
| 7 | 1024px | Laptop / large tablet |
| 8 | 320px | Smallest phone (~3% traffic, good stress test) |
| 9 | 430px | iPhone Pro Max |
| 10 | 1440px | Large laptop |
---
The 10-Point Check (At Each Viewport)
1. No horizontal scroll — any at all is a failure 2. Text overflow — long words, URLs, usernames breaking containers 3. Images and media — not overflowing, max-width: 100% applied 4. Touch targets — 44px minimum on mobile viewports 5. Navigation — reachable and usable at this width 6. Typography — readable (16px min body), no clipping 7. Flex/grid wrapping — wrapping where expected, not where it shouldn't 8. Positioned elements — not overlapping or off-screen 9. Z-index stacking — modals, dropdowns, tooltips rendering correctly 10. Sticky elements — sticking as intended, not overlapping each other
---
Edge Cases That Bite
| Edge case | Why it matters |
|---|---|
| 280px (Galaxy Fold inner screen) | Overflow here usually signals a deeper problem |
| 768x1024 portrait | Tablets in portrait are often treated as desktop incorrectly |
| Browser zoom 200% | WCAG 2.1 AA requirement. Text must reflow; containers must not overflow |
| RTL text | If localizable, Arabic/Hebrew reverses all directional assumptions |
| Long unbreakable strings | Email addresses, URLs, API keys. Use overflow-wrap: break-word |
| Missing images | Does layout collapse? Does alt text overflow? |
| 2560px+ | Content stretches; test max-width containers |
| Landscape phone | Different safe area insets; often forgotten |
| iOS "Larger Text" | System accessibility setting. px values ignore it; rem respects it |
| Keyboard open on mobile | Fixed bottom elements get displaced |
---
The Three-Tier Testing Strategy
Tier 1: During Development (Continuous)
Chrome DevTools drag-resize while building. After every meaningful CSS change:
- Drag from 320px to 1920px
- Check each breakpoint transition
- Check 50px above and below each breakpoint
Tier 2: After a Feature (Automated)
Screenshot comparison at key viewports using Playwright or dev-browser:
const viewports = [
{ width: 375, height: 812, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1440, height: 900, name: 'desktop' },
];
for (const vp of viewports) {
test(`layout at ${vp.name}`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto('/');
await expect(page).toHaveScreenshot(`home-${vp.name}.png`);
});
}First run creates baselines. Subsequent runs fail with a diff image when layout changes unexpectedly.
Tier 3: Before Shipping (Real Devices)
Spot check on actual devices: 1. iPhone (iOS Safari) — most different from Chrome in viewport handling 2. Android phone (Chrome) — 360-412px 3. iPad portrait — 768px, often missed
iOS Safari has the most quirks: viewport unit behavior, safe areas, scroll momentum.
---
Testing Container Queries
Container queries don't respond to viewport — they respond to parent element width. To test in automation:
test('card adapts in narrow container', async ({ page }) => {
await page.goto('/components/card');
await page.evaluate(() => {
const container = document.querySelector('.card-wrapper');
container.style.width = '300px';
});
const card = page.locator('.card');
await expect(card).toHaveCSS('flex-direction', 'column');
});---
Quick Screenshot Comparison (No Test Suite)
When you don't want to set up full Playwright tests:
1. Open DevTools > Device Mode 2. Set to Responsive, width = 375px 3. More Options > Capture Full Size Screenshot 4. Save as page-375.png 5. Repeat at 768px and 1440px 6. Compare visually or with an image diff tool
Low overhead, useful for one-off audits and before/after comparisons.
---
What to Document in a Behavior Spec
Before coding responsive layouts, describe expected behavior per component:
Component: ProductCard
| Viewport | Layout | Image | Text |
|------------|---------------------|--------------|--------------|
| < 640px | stack vertical | full width | 3 lines max |
| 640-1023px | horizontal, img left| 40% width | 2 lines max |
| 1024px+ | grid card, 320px | top, full-w | 1 line |This table takes 5 minutes and catches "what should happen at X width?" before writing CSS. It's the code-first equivalent of seeing all breakpoints on a canvas.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Responsive Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #141414;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.toolbar-title {
font-size: 13px;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
}
.toolbar-url {
flex: 1;
font-size: 13px;
color: #aaa;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
padding: 6px 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn {
font-size: 12px;
padding: 6px 12px;
border: 1px solid #333;
border-radius: 6px;
background: #1a1a1a;
color: #ccc;
cursor: pointer;
white-space: nowrap;
}
.btn:hover { background: #252525; border-color: #444; }
.viewport-container {
flex: 1;
display: flex;
gap: 16px;
padding: 16px;
overflow-x: auto;
overflow-y: hidden;
align-items: stretch;
}
.viewport-frame {
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
overflow: hidden;
}
.viewport-label {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #1a1a1a;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
flex-shrink: 0;
}
.viewport-label-width {
font-weight: 600;
color: #e0e0e0;
}
.viewport-label-name {
color: #666;
}
.viewport-frame iframe {
flex: 1;
border: none;
background: white;
}
.no-url {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.no-url h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
color: #ccc;
}
.no-url p {
font-size: 14px;
color: #666;
line-height: 1.6;
}
.no-url code {
background: #1a1a1a;
padding: 2px 8px;
border-radius: 4px;
font-size: 13px;
color: #aaa;
}
</style>
</head>
<body>
<div class="toolbar">
<span class="toolbar-title">Responsive Preview</span>
<span class="toolbar-url" id="url-display">—</span>
<div class="toolbar-actions">
<button class="btn" onclick="refreshAll()" title="Reload all viewports">Refresh All</button>
<button class="btn" onclick="toggleScale()" id="scale-btn" title="Toggle between actual size and scaled-to-fit">Scale: Off</button>
</div>
</div>
<div class="viewport-container" id="viewports"></div>
<script>
const DEFAULT_BREAKPOINTS = [
{ width: 375, name: 'Mobile' },
{ width: 768, name: 'Tablet' },
{ width: 1024, name: 'Laptop' },
{ width: 1440, name: 'Desktop' },
];
const BREAKPOINT_NAMES = {
320: 'Small Mobile',
375: 'Mobile',
390: 'Mobile',
414: 'Mobile L',
430: 'Mobile XL',
640: 'Small Tablet',
768: 'Tablet',
1024: 'Laptop',
1280: 'Desktop',
1440: 'Desktop L',
1920: 'Full HD',
2560: 'Ultra Wide',
};
let scaled = false;
function getParams() {
const params = new URLSearchParams(window.location.search);
const url = params.get('url');
const bpParam = params.get('breakpoints');
let breakpoints;
if (bpParam) {
breakpoints = bpParam.split(',').map(w => {
const width = parseInt(w.trim(), 10);
return { width, name: BREAKPOINT_NAMES[width] || `${width}px` };
}).filter(bp => !isNaN(bp.width));
} else {
breakpoints = DEFAULT_BREAKPOINTS;
}
return { url, breakpoints };
}
function renderViewports(url, breakpoints) {
const container = document.getElementById('viewports');
const urlDisplay = document.getElementById('url-display');
// Validate URL scheme — only allow http/https to prevent XSS via javascript: or data: URLs
if (url && !/^https?:\/\//i.test(url)) {
container.innerHTML = `
<div class="no-url">
<div>
<h2>Invalid URL scheme</h2>
<p>Only <code>http://</code> and <code>https://</code> URLs are supported.</p>
</div>
</div>`;
return;
}
if (!url) {
container.innerHTML = `
<div class="no-url">
<div>
<h2>No URL specified</h2>
<p>
Open this page with a URL parameter:<br><br>
<code>preview.html?url=http://localhost:3000</code><br><br>
Or use the launcher script:<br><br>
<code>node preview.js http://localhost:3000</code>
</p>
</div>
</div>`;
return;
}
urlDisplay.textContent = url;
container.innerHTML = '';
breakpoints.forEach(bp => {
const frame = document.createElement('div');
frame.className = 'viewport-frame';
frame.style.width = `${bp.width + 2}px`; // +2 for borders
const label = document.createElement('div');
label.className = 'viewport-label';
label.innerHTML = `
<span class="viewport-label-width">${bp.width}px</span>
<span class="viewport-label-name">${bp.name}</span>`;
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.width = `${bp.width}px`;
iframe.setAttribute('loading', 'lazy');
frame.appendChild(label);
frame.appendChild(iframe);
container.appendChild(frame);
});
}
function refreshAll() {
const iframes = document.querySelectorAll('.viewport-frame iframe');
iframes.forEach(iframe => {
iframe.src = iframe.src;
});
}
function toggleScale() {
scaled = !scaled;
const btn = document.getElementById('scale-btn');
const frames = document.querySelectorAll('.viewport-frame');
const container = document.getElementById('viewports');
if (scaled) {
btn.textContent = 'Scale: On';
const containerHeight = container.clientHeight - 32;
const containerWidth = container.clientWidth;
const { breakpoints } = getParams();
const totalNaturalWidth = breakpoints.reduce((sum, bp) => sum + bp.width + 18, 0); // +18 for gap+borders
const scaleFactor = Math.min(1, containerWidth / totalNaturalWidth);
frames.forEach(frame => {
frame.style.transform = `scale(${scaleFactor})`;
frame.style.transformOrigin = 'top left';
});
} else {
btn.textContent = 'Scale: Off';
frames.forEach(frame => {
frame.style.transform = '';
});
}
}
// Initialize
const { url, breakpoints } = getParams();
renderViewports(url, breakpoints);
</script>
</body>
</html>
#!/usr/bin/env node
const { execFile, spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const SCRIPTS_DIR = __dirname;
const PREVIEW_HTML = path.join(SCRIPTS_DIR, 'preview.html');
const SERVE_SCRIPT = path.join(SCRIPTS_DIR, 'serve-static.js');
function parseArgs(args) {
let target = null;
let breakpoints = null;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--breakpoints' && args[i + 1]) {
breakpoints = args[i + 1];
i++;
} else if (!target) {
target = args[i];
}
}
return { target, breakpoints };
}
function isUrl(str) {
return /^https?:\/\//i.test(str);
}
function openInBrowser(url) {
const cmd = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
execFile(cmd, [url], (err) => {
if (err) console.error(`Could not open browser: ${err.message}`);
});
}
function buildPreviewUrl(port, targetUrl, breakpoints) {
const params = new URLSearchParams();
params.set('url', targetUrl);
if (breakpoints) params.set('breakpoints', breakpoints);
return `http://localhost:${port}/_responsive-preview.html?${params.toString()}`;
}
function startServer(serveDir, onReady) {
// Copy preview.html into the serve directory so it's served over HTTP
const previewDest = path.join(serveDir, '_responsive-preview.html');
fs.copyFileSync(PREVIEW_HTML, previewDest);
const server = spawn('node', [SERVE_SCRIPT, serveDir], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let output = '';
let ready = false;
const startupTimeout = setTimeout(() => {
if (!ready) {
console.error('Server failed to start within 10 seconds.');
server.kill();
try { fs.unlinkSync(previewDest); } catch {}
process.exit(1);
}
}, 10000);
server.stdout.on('data', (data) => {
const text = data.toString();
output += text;
process.stdout.write(text);
if (!ready) {
const match = output.match(/SERVING_PORT:(\d+)/);
if (match) {
ready = true;
clearTimeout(startupTimeout);
onReady(parseInt(match[1], 10));
}
}
});
server.stderr.on('data', (data) => {
process.stderr.write(data);
});
server.on('close', (code) => {
clearTimeout(startupTimeout);
try { fs.unlinkSync(previewDest); } catch {}
if (code !== 0 && !ready) {
console.error(`Server exited with code ${code}`);
}
});
function cleanup() {
server.kill();
try { fs.unlinkSync(previewDest); } catch {}
process.exit(0);
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
return server;
}
// Main
const { target, breakpoints } = parseArgs(process.argv.slice(2));
if (!target) {
console.log(`
Responsive Preview — See all breakpoints at once
Usage:
node preview.js <url> Preview a running dev server
node preview.js <path> Preview a static HTML file
node preview.js <url> --breakpoints 320,768,1920
Examples:
node preview.js http://localhost:3000
node preview.js ./index.html
node preview.js http://localhost:5173 --breakpoints 375,768,1024,1440,1920
`);
process.exit(0);
}
if (isUrl(target)) {
// Dev server already running — serve preview.html from a temp directory
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'responsive-preview-'));
console.log(`Launching responsive preview for ${target}...\n`);
// Clean up temp dir on exit
const origCleanup = process.listeners('SIGINT');
process.on('exit', () => {
try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
});
startServer(tmpDir, (port) => {
const previewUrl = buildPreviewUrl(port, target, breakpoints);
console.log(`\nPreview: ${previewUrl}\n`);
openInBrowser(previewUrl);
});
} else {
// Static file — serve the target directory with preview.html alongside it
const resolvedPath = path.resolve(target);
if (!fs.existsSync(resolvedPath)) {
console.error(`Error: Path does not exist: ${resolvedPath}`);
process.exit(1);
}
const stat = fs.statSync(resolvedPath);
const serveDir = stat.isDirectory() ? resolvedPath : path.dirname(resolvedPath);
const fileName = stat.isDirectory() ? 'index.html' : path.basename(resolvedPath);
console.log(`Launching responsive preview for ${resolvedPath}...\n`);
startServer(serveDir, (port) => {
const targetUrl = `http://localhost:${port}/${fileName}`;
const previewUrl = buildPreviewUrl(port, targetUrl, breakpoints);
console.log(`\nPreview: ${previewUrl}\n`);
openInBrowser(previewUrl);
});
}
#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const MIME_TYPES = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.mp4': 'video/mp4',
'.webp': 'image/webp',
'.webm': 'video/webm',
};
const LIVE_RELOAD_SCRIPT = `
<script>
(function() {
var source = new EventSource('/__reload');
source.onmessage = function() { location.reload(); };
source.onerror = function() { source.close(); };
})();
</script>
`;
const rootDir = path.resolve(process.argv[2] || '.');
let startPort = parseInt(process.argv[3], 10) || 8787;
if (!fs.existsSync(rootDir)) {
console.error(`Error: Directory does not exist: ${rootDir}`);
process.exit(1);
}
// Track SSE clients for live reload
const reloadClients = [];
// Watch for file changes
let debounceTimer;
fs.watch(rootDir, { recursive: true }, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
reloadClients.forEach(res => {
try { res.write('data: reload\n\n'); } catch {
// Remove dead client on write failure
const idx = reloadClients.indexOf(res);
if (idx !== -1) reloadClients.splice(idx, 1);
}
});
}, 150);
});
function serve(port) {
const server = http.createServer((req, res) => {
// SSE endpoint for live reload
if (req.url === '/__reload') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
res.write('data: connected\n\n');
reloadClients.push(res);
req.on('close', () => {
const idx = reloadClients.indexOf(res);
if (idx !== -1) reloadClients.splice(idx, 1);
});
return;
}
let filePath = path.resolve(rootDir, decodeURIComponent(req.url).replace(/^\/+/, ''));
// Default to index.html for directory requests
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
filePath = path.join(filePath, 'index.html');
}
// Prevent path traversal — canonicalize both paths before comparing
const canonicalRoot = path.resolve(rootDir) + path.sep;
const canonicalFile = path.resolve(filePath);
if (!canonicalFile.startsWith(canonicalRoot) && canonicalFile !== path.resolve(rootDir)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`404 Not Found: ${req.url}`);
return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
// Inject live reload script into HTML files
if (ext === '.html') {
let html = data.toString();
if (html.includes('</body>')) {
html = html.replace('</body>', `${LIVE_RELOAD_SCRIPT}</body>`);
} else {
html += LIVE_RELOAD_SCRIPT;
}
res.writeHead(200, { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*' });
res.end(html);
} else {
res.writeHead(200, { 'Content-Type': contentType, 'Access-Control-Allow-Origin': '*' });
res.end(data);
}
});
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
serve(port + 1);
} else {
console.error(`Server error: ${err.message}`);
process.exit(1);
}
});
server.listen(port, () => {
// Output the port on its own line so the launcher can parse it
console.log(`SERVING_PORT:${port}`);
console.log(`Serving ${rootDir} at http://localhost:${port}`);
console.log('Live reload enabled — file changes trigger browser refresh');
});
}
serve(startPort);
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const DEFAULT_BREAKPOINTS = [375, 768, 1024, 1440];
const DEFAULT_HEIGHT = 900;
const DEV_BROWSER_TMP = path.join(require('os').homedir(), '.dev-browser', 'tmp');
function parseArgs(args) {
let url = null;
let breakpoints = DEFAULT_BREAKPOINTS;
let isBefore = false;
let outputDir = './snapshots';
for (let i = 0; i < args.length; i++) {
if (args[i] === '--breakpoints' && args[i + 1]) {
breakpoints = args[i + 1].split(',').map(w => parseInt(w.trim(), 10)).filter(n => !isNaN(n));
i++;
} else if (args[i] === '--before') {
isBefore = true;
} else if (args[i] === '--output' && args[i + 1]) {
outputDir = args[i + 1];
i++;
} else if (!url) {
url = args[i];
}
}
return { url, breakpoints, isBefore, outputDir };
}
function captureScreenshot(url, width, outputName) {
// Use JSON.stringify to safely escape the URL for embedding in JavaScript
const safeUrl = JSON.stringify(url);
const safeOutputName = JSON.stringify(outputName);
const script = `
const page = await browser.newPage();
await page.setViewportSize({ width: ${width}, height: ${DEFAULT_HEIGHT} });
await page.goto(${safeUrl}, { waitUntil: "networkidle" });
const buf = await page.screenshot({ fullPage: true });
const savedPath = await saveScreenshot(buf, ${safeOutputName});
console.log(savedPath);
`;
try {
const result = execSync(`dev-browser --headless <<'DEVEOF'\n${script}\nDEVEOF`, {
encoding: 'utf8',
timeout: 30000,
}).trim();
return result.split('\n').pop(); // Last line is the path
} catch (err) {
if (err.message && err.message.includes('ENOENT')) {
console.error('Error: dev-browser is not installed or not in PATH.');
console.error('Install it to use the snapshot feature.');
process.exit(1);
}
throw err;
}
}
function generateCompositeHtml(screenshots, breakpoints) {
const images = screenshots.map((shot, i) => {
const imgData = fs.readFileSync(shot);
const base64 = imgData.toString('base64');
return `
<div style="flex-shrink:0; text-align:center;">
<div style="font-size:14px; font-weight:600; margin-bottom:8px; color:#e0e0e0;">
${breakpoints[i]}px
</div>
<img src="data:image/png;base64,${base64}" style="border:1px solid #333; border-radius:4px;" />
</div>`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Responsive Snapshots</title>
<style>
body { margin:0; padding:24px; background:#0a0a0a; font-family:system-ui,sans-serif; }
.container { display:flex; gap:24px; overflow-x:auto; padding-bottom:16px; }
</style>
</head>
<body>
<div class="container">${images}</div>
</body>
</html>`;
}
function generateComparisonHtml(beforeShots, afterShots, breakpoints) {
const pairs = breakpoints.map((bp, i) => {
const beforePath = beforeShots[i];
const afterPath = afterShots[i];
if (!beforePath || !afterPath) return '';
const beforeData = fs.readFileSync(beforePath).toString('base64');
const afterData = fs.readFileSync(afterPath).toString('base64');
return `
<div style="flex-shrink:0; text-align:center;">
<div style="font-size:14px; font-weight:600; margin-bottom:8px; color:#e0e0e0;">
${bp}px
</div>
<div style="display:flex; gap:8px;">
<div>
<div style="font-size:11px; color:#888; margin-bottom:4px;">BEFORE</div>
<img src="data:image/png;base64,${beforeData}" style="border:1px solid #555; border-radius:4px; max-width:${bp}px;" />
</div>
<div>
<div style="font-size:11px; color:#888; margin-bottom:4px;">AFTER</div>
<img src="data:image/png;base64,${afterData}" style="border:1px solid #555; border-radius:4px; max-width:${bp}px;" />
</div>
</div>
</div>`;
}).join('\n');
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Responsive Comparison — Before / After</title>
<style>
body { margin:0; padding:24px; background:#0a0a0a; font-family:system-ui,sans-serif; }
h1 { font-size:18px; color:#ccc; margin-bottom:20px; }
.container { display:flex; gap:32px; overflow-x:auto; padding-bottom:16px; }
</style>
</head>
<body>
<h1>Before / After Comparison</h1>
<div class="container">${pairs}</div>
</body>
</html>`;
}
// Main
const { url, breakpoints, isBefore, outputDir } = parseArgs(process.argv.slice(2));
if (!url) {
console.log(`
Responsive Snapshot — Capture screenshots at every breakpoint
Usage:
node snapshot.js <url> Capture at default breakpoints
node snapshot.js <url> --before Save as baseline for comparison
node snapshot.js <url> --breakpoints 320,768 Custom breakpoints
node snapshot.js <url> --output ./my-shots Custom output directory
Examples:
node snapshot.js http://localhost:3000
node snapshot.js http://localhost:3000 --before
node snapshot.js http://localhost:3000 --breakpoints 375,768,1024,1440,1920
`);
process.exit(0);
}
// Determine output paths
const targetDir = isBefore ? path.join(outputDir, 'before') : outputDir;
fs.mkdirSync(targetDir, { recursive: true });
console.log(`Capturing ${breakpoints.length} breakpoints for ${url}...`);
console.log(`Breakpoints: ${breakpoints.join(', ')}px`);
console.log(`Output: ${path.resolve(targetDir)}\n`);
const screenshots = [];
for (const width of breakpoints) {
const name = `snapshot-${width}.png`;
process.stdout.write(` ${width}px... `);
try {
const tmpPath = captureScreenshot(url, width, name);
// Copy from dev-browser tmp to our output directory
const destPath = path.join(targetDir, name);
fs.copyFileSync(tmpPath, destPath);
screenshots.push(destPath);
console.log('done');
} catch (err) {
console.log(`failed: ${err.message}`);
screenshots.push(null);
}
}
// Filter breakpoints alongside screenshots so labels stay aligned
const validIndices = screenshots.map((s, i) => s ? i : -1).filter(i => i >= 0);
const validShots = validIndices.map(i => screenshots[i]);
const validBreakpoints = validIndices.map(i => breakpoints[i]);
if (validShots.length === 0) {
console.error('\nNo screenshots captured. Check that the URL is accessible.');
process.exit(1);
}
if (validShots.length < breakpoints.length) {
console.log(`\nWarning: ${breakpoints.length - validShots.length} of ${breakpoints.length} breakpoints failed to capture.`);
}
// Generate composite HTML
const compositeHtml = generateCompositeHtml(validShots, validBreakpoints);
const compositeHtmlPath = path.join(targetDir, 'composite.html');
fs.writeFileSync(compositeHtmlPath, compositeHtml);
console.log(`\nComposite: ${compositeHtmlPath}`);
// Generate comparison if before/ exists and we're not in --before mode
if (!isBefore) {
const beforeDir = path.join(outputDir, 'before');
if (fs.existsSync(beforeDir)) {
console.log('\nBefore/ directory found — generating comparison...');
const beforeShots = breakpoints.map(bp => {
const p = path.join(beforeDir, `snapshot-${bp}.png`);
return fs.existsSync(p) ? p : null;
});
const matchingBps = breakpoints.filter((bp, i) => beforeShots[i] && screenshots[i]);
if (matchingBps.length > 0) {
const comparisonHtml = generateComparisonHtml(
matchingBps.map(bp => path.join(beforeDir, `snapshot-${bp}.png`)),
matchingBps.map(bp => path.join(outputDir, `snapshot-${bp}.png`)),
matchingBps
);
const comparisonPath = path.join(outputDir, 'comparison.html');
fs.writeFileSync(comparisonPath, comparisonHtml);
console.log(`Comparison: ${comparisonPath}`);
} else {
console.log('No matching breakpoints between before and after — skipping comparison.');
}
}
}
console.log('\nDone!');
Workflow: Build Responsive from Scratch
Build a new responsive site or component with responsive behavior designed in from the start.
Required Reading
Load these references before proceeding:
references/modern-css-patterns.md— CSS patterns for implementationreferences/responsive-design-forks.md— loaded when ambiguous patterns arisereferences/ai-failure-patterns.md— pre-flight scan before outputting code
---
Step 1: Understand What's Being Built
Before writing any CSS, understand the layout requirements.
Adaptive mode
Ask 2-3 focused questions:
1. "What are you building?" — Page type (landing, dashboard, app, form, content), key components, complexity level. 2. "What's your CSS setup?" — Tailwind, vanilla CSS, CSS-in-JS, component library? (If you can detect this from the codebase, skip the question and confirm: "I see you're using Tailwind — I'll output Tailwind classes.") 3. "What's the primary device?" — Mobile-first (default), desktop-first (admin tools), or equal priority?
Carry the framework context forward — all CSS output in subsequent steps must match the detected framework. See references/modern-css-patterns.md Framework Detection section.
Then proceed to Step 2 based on the answers.
Guided mode
Conduct a fuller discovery:
1. "What are you building and who's it for?" 2. "What's the primary device and usage context?" 3. "Are there any specific responsive challenges you're anticipating?" (complex tables, multi-panel layouts, sticky elements, etc.) 4. "Are you using a CSS framework?" (Tailwind, vanilla CSS, etc. — affects implementation patterns)
Present understanding and wait for confirmation before proceeding.
---
Step 2: Describe Responsive Behavior
This is the key step that compensates for not having a visual canvas. Before writing CSS, describe what should happen at each viewport.
Adaptive mode
For each major component or section, write a brief inline behavior note as a CSS comment:
/* ProductCard: stack vertical on mobile, horizontal at 640px, fixed 320px grid card at 1024px */Surface design forks as you encounter them — don't batch them. When you recognize a pattern from references/responsive-design-forks.md, stop, present the fork, get a decision, continue. This is how Adaptive compensates for not writing formal specs.
Guided mode
Build a behavior spec — a set of tables that serve as the responsive design contract. Present all tables for approval before writing any CSS.
RESPONSIVE BEHAVIOR SPEC
Component: [Name]
Type: [Reflow | Expand/contract | Reveal/hide | Transform]
| Viewport | Layout | Key changes |
|-------------|------------------|----------------------|
| < 640px | [description] | [what changes] |
| 640-1023px | [description] | [what changes] |
| 1024px+ | [description] | [what changes] |Behavior types:
- Reflow — content rearranges (columns stack, sidebar becomes drawer)
- Expand/contract — component gets bigger/smaller without restructuring
- Reveal/hide — content appears or disappears (labels visible on desktop, icons on mobile)
- Transform — component changes form entirely (nav bar → hamburger menu)
Write one table per major component. Wait gate: Present the complete behavior spec and get approval before coding. This spec becomes a reference the user can check implementation against later.
Surface forks early
During this step, identify patterns from references/responsive-design-forks.md and present options. Common forks at this stage:
- Sidebar decisions
- Navigation approach
- Table handling
- Dashboard panel strategy
---
Step 3: Establish Responsive Foundation
Set up the base responsive infrastructure before building components. Adapt these to the project's CSS approach — if using Tailwind, Chakra, or another framework, use its built-in responsive system rather than raw CSS. See the Framework Detection section in references/modern-css-patterns.md.
Global reset (vanilla CSS — skip if framework provides this)
*, *::before, *::after { box-sizing: border-box; }
img, video, svg { max-width: 100%; height: auto; }
input, textarea, select { font-size: max(16px, 1rem); }Viewport meta
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">Fluid tokens (if not using a framework's built-in scale)
:root {
/* Type scale — generate at utopia.fyi or use clamp() manually */
--text-sm: clamp(0.83rem, 0.78rem + 0.29vw, 1rem);
--text-base: clamp(1rem, 0.91rem + 0.43vw, 1.25rem);
--text-lg: clamp(1.2rem, 1.07rem + 0.63vw, 1.56rem);
--text-xl: clamp(1.44rem, 1.26rem + 0.89vw, 1.95rem);
--text-2xl: clamp(1.73rem, 1.48rem + 1.24vw, 2.44rem);
/* Spacing scale */
--space-s: clamp(0.75rem, 0.69rem + 0.29vw, 0.875rem);
--space-m: clamp(1rem, 0.93rem + 0.38vw, 1.25rem);
--space-l: clamp(1.5rem, 1.38rem + 0.57vw, 1.75rem);
--space-xl: clamp(2rem, 1.86rem + 0.71vw, 2.5rem);
--space-section: clamp(3rem, 8vw, 6rem);
/* Layout dimensions (update at breakpoints) */
--header-height: 56px;
--sidebar-width: 100%;
}
@media (min-width: 768px) {
:root { --header-height: 64px; }
}
@media (min-width: 1024px) {
:root {
--header-height: 72px;
--sidebar-width: 280px;
}
}Reduced motion
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}---
Step 4: Build Mobile-First
Follow the escalation model: intrinsic CSS → container queries → media queries.
For each component
1. Start intrinsic — Can auto-fit, flex-wrap, or clamp() handle it without any queries? 2. Add container queries if the component needs to adapt to its container size 3. Add media queries only for page-level structural changes
Apply the escalation check
Before adding a media query, ask: "Could this be solved with a container query or intrinsic sizing instead?" If yes, use the simpler tool.
Pre-flight check
Before outputting any responsive CSS, scan against references/ai-failure-patterns.md:
- Using
svh/dvh, not100vh? - Mobile-first (
min-width), not desktop-first? min-width: 0on flex children with dynamic content?align-self: starton sticky elements in flex/grid?- No
overflow: hiddenbreaking sticky parents? - Safe areas handled for fixed/sticky elements?
- Touch targets at least 44px?
Surface forks during implementation
As you build, you'll encounter patterns from references/responsive-design-forks.md. When you recognize one:
1. Stop implementing 2. Describe what you're seeing and why there's no single right answer 3. Present the options with tradeoffs 4. Ask the user to decide 5. Continue implementing with their choice
---
Step 5: Verify
Load references/testing-checklist.md for the 10-point check and priority viewport list.
What Claude can do: Scan the CSS output against references/ai-failure-patterns.md (static analysis). Flag things that need manual browser testing. Launch the multi-viewport preview or capture snapshots for visual verification.
Visual tools available:
node ${CLAUDE_SKILL_DIR}/scripts/preview.js <url>— opens all breakpoints side by side in the browsernode ${CLAUDE_SKILL_DIR}/scripts/snapshot.js <url>— captures screenshots at each breakpoint
What needs manual testing: Drag-resize behavior, real device quirks, touch interactions, keyboard open behavior.
Quick verification (Adaptive mode)
Run the pre-flight scan (ai-failure-patterns.md checklist) against all output. Run through the 10-point check at 375px, 768px, and 1440px mentally — flag any likely issues.
Thorough verification (Guided mode)
1. Compare implementation against the behavior specs from Step 2 2. Run the 10-point check at all priority viewports (375, 768, 1024, 1440) 3. Check edge cases relevant to this specific build 4. Run the pre-flight scan 5. Verify all design fork decisions were implemented correctly
Present verification summary:
VERIFICATION
Components built: [N]
Behavior specs matched: [Y/N per component]
Viewports checked: [list]
AI failure scan: [pass/issues found]
Design forks resolved: [N]Offer live preview: After presenting the summary, offer to launch the multi-breakpoint preview:
Want me to open a live responsive preview in your browser? You'll see 375px, 768px, 1024px, and 1440px side by side — you can scroll and navigate each one independently.
If they accept, run node ${CLAUDE_SKILL_DIR}/scripts/preview.js <url>.
---
Success Criteria
- [ ] Layout requirements understood
- [ ] Responsive behavior described before coding (tables or inline notes)
- [ ] Foundation established (reset, viewport meta, tokens)
- [ ] Built mobile-first using escalation model
- [ ] Design forks surfaced and decided by user
- [ ] Pre-flight scan passed
- [ ] Verification completed