ADR-002: Tailwind v4 Container Queries for Component-Internal Responsive Decisions
Status
Section titled “Status”Accepted
2026-05-01
Context
Section titled “Context”The museum’s gallery surface needs to respond to screen size at two different scopes:
- Page-level scaling — the gallery container itself caps at different widths depending on whether the user is on a phone, laptop, desktop, or 4K display. This is fundamentally a viewport concern: “this device class deserves more or less width.”
- Component-internal scaling — within the gallery, individual decisions like “split-vs-stacked lightbox layout” or “grid tile size” should respond to the component’s own available width, not the viewport. A lightbox embedded in a 600px-wide modal at a 4K viewport should still stack, not split.
Pre-this-change, every responsive decision used Tailwind’s viewport variants (md:, lg:). The lightbox split decision used md:flex-row based on viewport width, which accidentally worked because the lightbox is currently fullscreen — but the decision wasn’t future-proof: any future “lightbox in a sidebar” or “lightbox in an iframe embed” use case would render incorrectly.
Tailwind v4 ships native container queries (@container registers a container; @md: / @lg: / @[<custom>]: variants apply utilities at container breakpoints). Adopting them is a one-line annotation per container.
Decision
Section titled “Decision”Use container queries (@container) for component-internal responsive decisions; keep viewport media queries (md:, lg:, xl:, 2xl:) for whole-page tier ladders.
The gallery surface establishes the pattern:
Gallery container — viewport-tied
Section titled “Gallery container — viewport-tied”The page-level container ladder uses Tailwind’s standard viewport variants because “what device class is this?” is a viewport question:
<div className="@container/gallery mx-auto w-full px-5 pb-16 md:max-w-3xl lg:max-w-5xl xl:max-w-7xl 2xl:max-w-[1440px]">| Viewport | Container width |
|---|---|
< md (768) |
full-width with 20px padding |
md → lg |
768px |
lg → xl |
1024px |
xl → 2xl |
1280px |
2xl+ |
1440px (hard cap) |
Grid tile minmax — container-tied
Section titled “Grid tile minmax — container-tied”The grid tile size scales with the container, not the viewport, so an embedded gallery in a constrained context still picks the right tile size:
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] @[1280px]/gallery:grid-cols-[repeat(auto-fill,minmax(240px,1fr))] @[1440px]/gallery:grid-cols-[repeat(auto-fill,minmax(280px,1fr))]">Lightbox split decision — container-tied
Section titled “Lightbox split decision — container-tied”The lightbox content registers @container/lightbox and uses @md/lightbox: for its split-vs-stacked decision. Embedding it in a 600px-wide context renders the stacked variant regardless of viewport:
<div className="@container/lightbox max-h-[92vh] w-full overflow-y-auto px-5 @md/lightbox:px-10 @lg/lightbox:px-16"> <div className="flex flex-col gap-8 @md/lightbox:flex-row @md/lightbox:items-start @md/lightbox:gap-12">Consequences
Section titled “Consequences”Positive:
- Components are embed-safe by default — drop the lightbox into any container and it adapts to its actual width
- Future surfaces (a sidebar widget, a partner embed, a modal-in-a-modal) inherit responsive behavior correctly
- Tailwind v4 native — zero dependencies, zero plugin overhead
- The convention is documented and reusable: “if it’s a page size question, viewport variant; if it’s a space-I-have question, container query”
Negative:
- Slightly larger CSS footprint per container (Tailwind generates the variant rules)
- Container queries are baseline in evergreen browsers (Safari 16+, Chrome 105+, Firefox 110+) but not in legacy browsers — irrelevant for StoryKeep’s family-private audience on modern devices, but worth noting
- Mental model shift: developers need to know which scope a decision belongs to (rule of thumb above helps)
Alternatives Considered
Section titled “Alternatives Considered”Stay on viewport media queries. Rejected — accidentally works for fullscreen contexts but breaks the moment a component is embedded. Refactor cost is the same now or later; might as well lay the foundation while the surface is small.
Adopt container queries codebase-wide. Out of scope. The gallery surface is the worked example; other surfaces migrate to the pattern as they’re touched.
Use ResizeObserver + JS-driven layout. Heavier, slower, doesn’t compose with Tailwind, and re-implements what @container already gives us declaratively.
Related
Section titled “Related”- Tokens — radius variant tokens that ride alongside this change