Skip to content

ADR-002: Tailwind v4 Container Queries for Component-Internal Responsive Decisions

Accepted

2026-05-01

The museum’s gallery surface needs to respond to screen size at two different scopes:

  1. 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.”
  2. 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.

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:

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)

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))]">
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">

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)

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.

  • Tokens — radius variant tokens that ride alongside this change