---
name: responsive-architecture
description: "Use when a layout must adapt across screen sizes — breakpoints, container queries, fluid type, touch targets, zoom, or mobile overflow bugs."
---

<!--
  Generated by Vishwakarma. Do not edit this file directly.
  Edit the source skill and run `vishwakarma sync` to regenerate.
-->

# Responsive Architecture

A layout is not responsive because it has breakpoints. It is responsive because it stays
usable at every width it is given, including the widths nobody tested — which is most of
them. A breakpoint is a repair, and reaching for one should feel like an admission that the
layout could not solve the problem itself.

---

## 1. Breakpoints come from the content

Naming a breakpoint `tablet` encodes a false claim: that there is a device class 768px wide
whose users need a distinct layout. 1024px is simultaneously an iPad in landscape, a small
laptop, and a window someone dragged to half a monitor.

The procedure is empirical. Build at the narrowest supported width, then widen the viewport
slowly and watch. The breakpoint is the width at which something specifically fails: a
headline wraps to four lines, a navigation row runs out of space, a measure passes 75
characters. Put the breakpoint just past that failure and name it for the failure —
`--bp-nav-collapse`, `--bp-sidebar-fits`. Different components break at different widths,
so a project has more breakpoints than the framework default and each belongs to its own
component. Express them in `em`, not `px`: an `em` query is evaluated against the
browser's default font size, so a user who has raised theirs gets the simpler layout sooner —
correct, because less content now fits.

---

## 2. Intrinsic first, media queries as the exception

Most adaptation needs no query. `grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr))`
reflows a card grid at every width in one declaration — `auto-fill` instead when a lone item
should not stretch. `width: min(65ch, 100%)` is a max-width that cannot overflow its parent;
`fit-content(20rem)` sizes a sidebar to its content, up to a cap.

A media query changes layout in steps, so between two breakpoints the layout is frozen and
only accidentally correct — which is where untested dead zones live. Reach for one only when
the change is genuinely discontinuous: a nav becoming a drawer, two panes becoming a stack.

---

## 3. Fluid values: the algebra, and the trap

A fluid value interpolates linearly between a minimum at a narrow viewport and a maximum at a
wide one. With `slope = (maxPx - minPx) / (maxVwPx - minVwPx)` and
`intercept = minPx - slope * minVwPx`, the value is
`clamp(minRem, interceptRem + (slope * 100)vw, maxRem)` — for 16px at 320px growing to 20px
at 1280px, `clamp(1rem, 0.9167rem + 0.4167vw, 1.25rem)`.

**The `rem` term is not optional, and this is an accessibility requirement.** Viewport units
are computed from the viewport alone and ignore the root font size, so
`clamp(1rem, 2.5vw, 1.25rem)` locks text to the window across the whole fluid range: a user
who set their browser default to 24px because they need 24px gets 20px anyway. A `rem`
component makes the entire interpolation line shift when the root size does. State both
bounds in `rem` for the same reason. Keep the min-to-max ratio under roughly 1.6x, and check
that the smallest heading still outranks body text at the narrow anchor.

---

## 4. Container queries: a card does not know the viewport

A component's layout is a function of the space it was given, not of the window. The same card
sits in a 1200px feature slot and a 320px sidebar; a viewport query tells it "wide" in both.

    .card-wrap { container-type: inline-size; container-name: card; }
    @container card (min-width: 24rem) { .card { grid-template-columns: 8rem 1fr; } }

Three pitfalls cause most container-query bugs. **You cannot query the element you are
styling** — the query matches an ancestor, so the component needs a wrapper carrying
`container-type`. **`container-type: size` collapses height**: it contains both axes, so
the element stops sizing to its content and resolves to zero height unless one is set
explicitly; use `inline-size`. **Containment changes positioning** — a container is a
containing block for absolutely and fixed-positioned descendants, so a dropdown that used to
escape to the viewport is now clipped by the card. Use the popover API or a portal.

Size internals with `cqi`, not `vw`. Style queries — `@container style(--density: compact)`
— match custom property values rather than dimensions, and every element is a style container
by default, so they propagate a variant through a subtree without threading class names.

---

## 5. Pointer, hover, and target size

WCAG 2.2 SC 2.5.8 (AA) requires interactive targets of at least 24 by 24 CSS pixels unless
spacing leaves a non-overlapping 24px circle around each; SC 2.5.5 (AAA) sets 44 by 44. Treat
24px as the floor and 44px as the working target for a thumb, expanding the hit area with
padding rather than the control itself.

Detect input capability, never viewport width. `(hover: hover)` and `(pointer: fine)`
describe the primary pointer, `(any-pointer: coarse)` any attached one — a touchscreen
laptop reports a fine hovering primary and is still used with a finger. Gate hover
*enhancements* on `(hover: hover)`, size targets for `(any-pointer: coarse)`. Gating
matters because touch browsers emulate hover on tap: the state applies and sticks until the
user taps elsewhere. Worse, an affordance revealed only on hover — a row's delete action, a
menu that opens on hover — is absent for touch and keyboard alike.

---

## 6. The viewport is not 100vh

On mobile browsers `vh` resolves against the *large* viewport, the height with the URL bar
retracted, so a `100vh` element extends below the fold when the bar is visible and its
bottom content — usually the primary action — is unreachable. Use `svh` for anything that
must be visible immediately, `lvh` for the largest, `dvh` for a value that tracks the bar;
`dvh` reflows during scroll, so `min-height: 100svh` is the safer app-shell default.
`100vw` does not subtract a classic scrollbar in every engine, a common source of horizontal
overflow; prefer `100%` or `scrollbar-gutter: stable`.

For rounded corners and home indicators, add `viewport-fit=cover` to the viewport meta tag
and pad with `max(1rem, env(safe-area-inset-bottom))`; without that meta value the `env()`
insets resolve to zero.

---

## 7. Images and tables

**`sizes` is wrong by default.** With a `w`-descriptor `srcset` the browser assumes
`sizes="100vw"` and fetches an image for the whole window even if it renders at 400px,
deciding in the preload scanner before CSS exists to correct it. Declare the rendered
width, or use `sizes="auto"`, valid only on `loading="lazy"` images. Give every image
`width` and `height` or an `aspect-ratio` so its box is reserved before it loads, put
`fetchpriority="high"` on the LCP image and never `loading="lazy"`, and use `<picture>`
only for art direction — a different crop, not a different resolution.

**Tables are the hardest case**: a table is a two-dimensional relationship and a phone is one
column, so decide what the user is doing. Comparing across rows, the grid *is* the information —
keep the table, wrap it in an `overflow-x: auto` container that is focusable and labelled
(`tabindex="0"`, `role="region"`, an accessible name), and make the identifying column
sticky. Reading one record at a time, render a list of records — but change the markup, not
`display`, because `display: block` on table elements destroys the row and column
associations screen readers depend on. Hide columns only when they stay reachable in an
expandable row detail.

---

## 8. The failures, named

**Device-name breakpoints**, correct at five widths and arbitrary between them. **Hiding
content on mobile** instead of restructuring it. **Horizontal overflow** — almost always a
fixed `min-width`, an unbreakable string (`overflow-wrap: anywhere`), a negative margin, or
`100vw`. **Hover-only affordances**. **`100vh` on mobile**. **Untested zoom**: 200%
(SC 1.4.4) and 400% (SC 1.4.10) are conformance requirements, broken far more often than
320px.

## Rules

### MUST NOT — Do not size text using viewport or container units alone, with no rem or px term and no clamp bounds.

*Why:* Unbounded viewport-relative text has no floor and no ceiling: it becomes illegible on narrow screens and absurd on wide ones, and it never responds to the user font-size setting because viewport units are not derived from it.

### MUST NOT — Do not make any action or information available only on hover.

*Why:* Touch devices have no hover state and keyboard navigation produces focus rather than hover, so a hover-only affordance is simply absent for both. Revealing row actions or opening menus on hover removes functionality for the majority of traffic.

*Exceptions:*
- Purely redundant enhancement, where the same action is also reachable through a persistently visible control.

### MUST NOT — Do not use display: none at narrow widths as the strategy for fitting content on small screens.

*Why:* Hiding rather than restructuring makes the small-screen experience a strict subset of the large one, and small screens are the majority of traffic for most products. Content that matters at 1440px still matters at 375px; it needs a different arrangement, not deletion.

*Exceptions:*
- Genuinely decorative elements with no informational or functional content.
- Content that remains reachable through an equivalent path, such as a column moved into an expandable row detail.

### MUST NOT — Do not override the display property of table, row, or cell elements to restack a table on small screens.

*Why:* Row and column association is carried by the table display types, not by the element names. Setting display: block removes the elements from the table formatting context, and assistive technology loses the header-to-cell relationships that make the data interpretable at all.

*Exceptions:*
- The table is given explicit ARIA table roles that restore the structure, which is fragile and should be a last resort.

Incorrect:

```css
@media (max-width: 40em) { table, tr, td { display: block; } }
```

Correct:

```css
.table-scroll { overflow-x: auto; }
/* <div class="table-scroll" tabindex="0" role="region" aria-label="Invoices"> */
```

### MUST — Every fluid clamp() preferred term must include a rem component, and both bounds must be stated in rem.

*Why:* Viewport units are derived from the viewport alone and are entirely independent of the root font size, so a preferred term expressed purely in vw ignores the user font-size preference throughout the fluid range. A rem component makes the whole interpolation line shift when the root size changes.

*Source:* [WCAG 2.2 Success Criterion 1.4.4 (Resize Text)](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html)

Incorrect:

```css
h1 { font-size: clamp(2rem, 5vw, 3.5rem); }
```

Correct:

```css
h1 { font-size: clamp(2rem, 1.5rem + 2.5vw, 3.5rem); }
```

### MUST — The page must not scroll horizontally at a 320 CSS px viewport width.

*Why:* Reflow requires content to be presentable at 320 CSS px without two-dimensional scrolling, because that is the layout width a 1280px window produces at 400% zoom. Horizontal page scrolling at that width makes reading require a horizontal sweep on every line.

*Source:* [WCAG 2.2 Success Criterion 1.4.10 (Reflow)](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)

*Exceptions:*
- Content that genuinely requires two-dimensional layout — data tables, maps, diagrams, code blocks — which may scroll within its own region.

### MUST — Give every interactive target at least 24 by 24 CSS pixels, and at least 44 by 44 for primary controls on touch input.

*Why:* A fingertip contact patch is far larger than a cursor hotspot and the user cannot see what is under it, so acquisition error rises sharply below roughly 44px. 24px is the WCAG 2.2 Level AA floor; 44px matches the enhanced criterion and the major platform guidelines.

*Source:* [WCAG 2.2 Success Criteria 2.5.8 (AA, 24px) and 2.5.5 (AAA, 44px)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html)

*Exceptions:*
- Targets inline within a sentence, where the line box governs size.
- Targets whose spacing places a non-overlapping 24px circle around each one.

Incorrect:

```css
.icon-button { width: 16px; height: 16px; }
```

Correct:

```css
.icon-button { width: 16px; height: 16px; padding: 14px; box-sizing: content-box; }
```

### MUST — Gate hover-dependent styling behind @media (hover: hover).

*Why:* Touch browsers emulate a hover event on tap, so an ungated :hover rule applies on touch and persists until the user taps elsewhere, leaving controls stuck in a highlighted state that misrepresents the interface.

Incorrect:

```css
.card:hover { transform: translateY(-4px); }
```

Correct:

```css
@media (hover: hover) { .card:hover { transform: translateY(-4px); } }
```

### MUST — When the viewport meta tag sets viewport-fit=cover, pad fixed edge-anchored UI with env(safe-area-inset-*), combined with a design minimum using max().

*Why:* viewport-fit=cover extends the layout viewport under rounded corners, notches, and the home indicator. Without inset padding, edge-anchored controls are physically obscured or overlap the system gesture area; without the meta value the env() variables resolve to zero and the padding has no effect.

Incorrect:

```css
.tab-bar { padding-block-end: 1rem; }
```

Correct:

```css
.tab-bar { padding-block-end: max(1rem, env(safe-area-inset-bottom)); }
```

### MUST — Provide a sizes attribute that matches the rendered width whenever srcset uses w descriptors, or use sizes="auto" on lazy-loaded images.

*Why:* The default value of sizes is 100vw, so with no explicit value the browser selects a candidate sized for the whole window. An image rendered at 400px on a 1440px screen therefore downloads roughly three times the pixels it needs, and the selection happens in the preload scanner before CSS is available to correct it.

Incorrect:

```html
<img srcset="a-400.jpg 400w, a-800.jpg 800w, a-1600.jpg 1600w" src="a-800.jpg" alt="">
```

Correct:

```html
<img srcset="a-400.jpg 400w, a-800.jpg 800w, a-1600.jpg 1600w" sizes="(min-width: 60rem) 20rem, 100vw" src="a-800.jpg" alt="">
```

### MUST — Give every image and embedded media element intrinsic width and height attributes or an explicit aspect-ratio.

*Why:* Without intrinsic dimensions the element occupies no space until its bytes arrive, so surrounding content is laid out and then displaced. The shift is largest on slow mobile connections, which is exactly where a mis-tap costs the most.

### MUST — Make any horizontally scrolling region keyboard-focusable with tabindex="0" and give it an accessible name.

*Why:* A scroll container that contains no focusable elements cannot be reached or scrolled by keyboard, so its overflowing content is unreachable without a pointer. Making the container focusable gives it arrow-key scrolling, and the name tells a screen reader user what they have landed in.

### MUST — Verify every layout at 200% and 400% browser zoom before considering responsive work complete.

*Why:* Both are Level AA conformance requirements, and neither is exercised by width testing alone: zoom scales text and layout together, which exposes fixed-height containers, ellipsis truncation, and sticky chrome that consumes the whole 256px effective height at 400%.

*Source:* [WCAG 2.2 Success Criteria 1.4.4 (Resize Text) and 1.4.10 (Reflow)](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html)

### SHOULD NOT — Do not use container-type: size unless the element has an explicitly set block size.

*Why:* Size containment applies to both axes, which removes the contents from the element’s own size calculation. Without an explicit height the element resolves to zero height and its content overflows or disappears; inline-size contains only the inline axis and leaves height content-driven.

### SHOULD NOT — Do not use vh for full-height layout on mobile; use svh, lvh, or dvh according to which viewport state matters.

*Why:* On mobile browsers vh resolves against the large viewport, the height with browser chrome retracted. With the URL bar visible, a 100vh element is taller than the visible area and its bottom content — typically the primary action — sits below the fold.

Incorrect:

```css
.hero { height: 100vh; }
```

Correct:

```css
.hero { min-height: 100svh; }
```

### SHOULD — Choose each breakpoint by observing the width at which the layout actually fails, and name it for that failure rather than for a device class.

*Why:* Screen widths form a continuum with no clustering around device names, so a breakpoint derived from a device is correct only by coincidence. A breakpoint derived from an observed failure is correct by construction, and its name tells the next reader what it protects.

Incorrect:

```css
@media (min-width: 768px) { .nav { display: flex; } } /* "tablet" */
```

Correct:

```css
/* nav items stop fitting on one row below 46em */
@media (min-width: 46em) { .nav { display: flex; } }
```

### SHOULD — Express media query breakpoints in em units rather than px.

*Why:* An em breakpoint is evaluated against the browser default font size, so a user who raises theirs receives the simpler layout at a proportionally wider viewport — which is correct, because their larger text fits less content in the same space.

### SHOULD — Solve continuous layout adaptation with intrinsic sizing (auto-fit grids, flex wrapping, min/max/clamp) and reserve media queries for genuinely discontinuous changes.

*Why:* A media query changes layout in steps, so between two breakpoints the layout is fixed and only accidentally correct. Intrinsic sizing responds at every width, which removes the untested dead zones where most responsive defects live.

Incorrect:

```css
.grid { grid-template-columns: 1fr; }
@media (min-width: 40em) { .grid { grid-template-columns: 1fr 1fr; } }
@media (min-width: 64em) { .grid { grid-template-columns: repeat(3, 1fr); } }
```

Correct:

```css
.grid { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
```

### SHOULD — Adapt reusable components with container queries against their own container, not with viewport media queries.

*Why:* A component receives its width from its parent, not from the window. A viewport query gives the same answer to the same component in a 1200px slot and a 300px sidebar, so any component placed in more than one context is wrong in at least one of them.

*Exceptions:*
- Page-level chrome such as the primary navigation or an app shell, which is genuinely a function of the viewport.

### SHOULD — Move popovers, dropdowns, and tooltips out of query containers, or render them with the popover API or a portal.

*Why:* A query container establishes a containing block for absolutely and fixed-positioned descendants, so overlay content that previously escaped to the viewport becomes positioned and clipped relative to the container.

## Before reporting completion

Run these checks against your own output. Answer each question explicitly rather than
assuming the answer, because the point of the exercise is to notice what you did not
notice while building.

### Confirm the layout holds across the full viewport and zoom matrix. (blocking)

- At 320 CSS px, does the page scroll horizontally anywhere, and does any long unbroken string push the layout wider?
- Between 640px and 1024px, is there any width where the layout is visibly unfinished — a lone orphan card, a half-collapsed navigation, a sidebar too narrow to use?
- At 200% zoom, is any content clipped by a fixed height or truncated by an ellipsis?
- At 400% zoom on a 1280x1024 window, does any content require scrolling in two directions, and do fixed headers and footers leave usable space in the remaining 256px of height?
- In landscape on a short viewport, can every modal and every primary action still be reached?

### Confirm fluid values respond to user font-size preference and stay ordered. (blocking)

- Does every clamp() preferred term contain a rem component, and are both bounds stated in rem?
- Tabulate each type step at 320px, 768px, and 1280px: does the ordering of steps hold at all three, with no pair converging?
- With the browser default font size raised to 24px, does body text visibly grow?
- Is any border width, radius, or line-height fluid that should not be?

### Confirm the interface works with a coarse pointer and without hover. (blocking)

- List every interactive target smaller than 24x24 CSS px. Is each one inline in a sentence, or spaced so a 24px circle around it overlaps nothing?
- Is every :hover rule inside an @media (hover: hover) block?
- Is any action, label, or control revealed only on hover? If so, how does a touch or keyboard user reach it?
- Are targets sized for @media (any-pointer: coarse) rather than for a narrow viewport width?

### Confirm component adaptation is scoped to the container, not the window.

- Would this component still lay out correctly if placed in a 300px sidebar on a 1440px screen?
- Does every query container use container-type: inline-size, or does it set an explicit height to justify size?
- Does any container contain an absolutely or fixed-positioned overlay that now resolves against the container instead of the viewport?

### Fail when a layout dimension is set in vh, which resolves to the large viewport on mobile and hides content below the fold.

```bash
! grep -rInE '(min-|max-)?height[[:space:]]*:[[:space:]]*[0-9.]+vh' --include='*.css' --include='*.scss' --include='*.tsx' --include='*.jsx' --include='*.vue' --include='*.svelte' .
```

### Evaluate the output against the responsive section of the project Design Contract. (blocking)

Evaluate the output against the project Design Contract (responsive section).

Run `vishwakarma audit` if the project has the CLI available.

## Further reference

These are not loaded by default. Read one only when its question is the question you
currently have.

- `references/viewport-test-matrix.md` — Which viewport widths and zoom levels must I test, and what specifically am I looking for at each one?
- `references/fluid-scale-construction.md` — How do I derive the exact clamp() values for a whole type and spacing scale, and what makes a fluid scale fail?
