|
| 1 | +--- |
| 2 | +name: migrate-container-queries |
| 3 | +description: Guide for migrating viewport media queries (@media, useMedia) to container queries in Sentry's frontend. Use when migrating responsive layout to container queries, replacing @media/useMedia, refactoring styled responsive components to Container/Flex/Grid primitives, or working on the DE container-query migration. |
| 4 | +--- |
| 5 | + |
| 6 | +# Container Query Migration Guide |
| 7 | + |
| 8 | +Migrate viewport-based responsive logic (`@media` + `useMedia`) to container queries so components respond to their own available space instead of the raw viewport. |
| 9 | + |
| 10 | +> **Always do a visual check.** After every migration, resize the _element_ (not just the window) and confirm the layout is identical and flips at the intended width. A good way to narrow an element without touching the window is to open a resizable panel next to it — e.g. drag out the Seer explorer sidebar, which squeezes the middle content. The token scales differ, so a mechanical swap that compiles can still render wrong. |
| 11 | +
|
| 12 | +## Approach: refactor first, swap second |
| 13 | + |
| 14 | +Stop at the first rung that fits. Prefer replacing hand-rolled CSS with primitives over a mechanical token swap. |
| 15 | + |
| 16 | +| Rung | When | Do | |
| 17 | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | |
| 18 | +| 1. Primitive props | The `@media` only flips layout (`flex-direction`, `display`, `grid-template`, gap, visibility, width) | Delete the styled component; use `Container`/`Flex`/`Grid`/`Stack` responsive props (`direction={{xs: 'column', md: 'row'}}`) | |
| 19 | +| 2. `@container` swap | CSS can't be a prop (descendant selectors, pseudo-elements, `font-size`, complex `grid-template-areas`) | Keep the styled component; swap `@media` → `@container`, `theme.breakpoints.*` → `theme.container.*` | |
| 20 | +| 3. Container-scoped JS | Width is read in JS to branch rendering | Replace `useMedia(...)` with `useResponsivePropValue({...})` for a threshold boolean, or `useContainerBreakpoint()` to branch on the active key | |
| 21 | +| 4. Leave as `useMedia` | Genuine media feature, not width | Do nothing — these do not migrate | |
| 22 | + |
| 23 | +## ⚠️ Convert to the nearest container scale |
| 24 | + |
| 25 | +Breakpoint and container scales have **different keys and different pixel values** — this is not a rename. **MAP BY PIXEL VALUE, NOT BY KEY:** `breakpoints.sm` does NOT become `container.sm`. Reusing the same key is the #1 migration bug. |
| 26 | + |
| 27 | +`theme.breakpoints` (viewport / `@media`), base `2xs`: |
| 28 | + |
| 29 | +| `2xs` | `xs` | `sm` | `md` | `lg` | `xl` | `2xl` | |
| 30 | +| ----- | ----- | ----- | ----- | ------ | ------ | ------ | |
| 31 | +| 0px | 500px | 800px | 992px | 1200px | 1440px | 2560px | |
| 32 | + |
| 33 | +`theme.container` (container / `@container`), base `zero`: |
| 34 | + |
| 35 | +| `zero` | `3xs` | `2xs` | `xs` | `sm` | `md` | `lg` | `xl` | `2xl` | `3xl` | `4xl` | `5xl` | |
| 36 | +| ------ | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ------ | ------ | ------ | |
| 37 | +| 0px | 320px | 384px | 448px | 512px | 576px | 640px | 768px | 896px | 1024px | 1152px | 1280px | |
| 38 | + |
| 39 | +**Rule:** take the old breakpoint's pixel value and pick the `container` token whose pixel value is _nearest_ to it — not the token with the same name. `breakpoints.sm` is 800px, so it maps to `container.xl` (768px), not `container.sm` (512px). Then confirm with a visual check: the container is often narrower than the viewport, so the nearest-px token is a starting point, not a guarantee. |
| 40 | + |
| 41 | +## Genuine viewport width → `screen:` keys, not `useMedia` |
| 42 | + |
| 43 | +When layout truly must follow the _window_ (not the component's room), don't keep `useMedia` — use a `screen:`-prefixed responsive prop, which resolves against the viewport on the `theme.breakpoints` scale: `direction={{zero: 'column', 'screen:lg': 'row'}}`. Bare keys and `screen:` keys can mix on one prop. Prefer bare (container) keys; reach for `screen:` only when the viewport genuinely drives the layout. |
| 44 | + |
| 45 | +## Keep `useMedia` only for non-width media features |
| 46 | + |
| 47 | +Width — container or viewport — has a prop/hook path above. Leave `useMedia` in place only for: |
| 48 | +`prefers-color-scheme`, `prefers-reduced-motion`, `hover`, `pointer`, `max-height` / height-based, `resolution`, `print`. |
| 49 | + |
| 50 | +## container-type: only when no query container is in scope |
| 51 | + |
| 52 | +**Default: don't add one.** Bare keys and `@container` already resolve against the nearest ancestor container, and product views have one: `ContentStack` (`#main`, `views/organizationLayout/index.tsx`) wraps the routed `<Outlet />` with `containerType="inline-size"`; `topBar` and `#modal-portal` cover their own subtrees. Add `container-type` only when a subtree must respond to _its own_ width rather than the page's — then: |
| 53 | + |
| 54 | +- Use `inline-size` (width only). `size` also queries height, which collapses content unless height is set elsewhere. |
| 55 | +- In a reusable component that may already sit inside a container, make it conditional to avoid a redundant one — `containerType={hasParentQueryContainer ? 'normal' : 'inline-size'}` via `useHasContainerQuery()` (see `components/core/breadcrumbList/breadcrumbList.tsx`). |
| 56 | + |
| 57 | +## Examples |
| 58 | + |
| 59 | +### Rung 1 — styled `@media` → primitive props (preferred) |
| 60 | + |
| 61 | +```tsx |
| 62 | +// Old — delete the styled component |
| 63 | +const Row = styled('div')` |
| 64 | + display: flex; |
| 65 | + flex-direction: row; |
| 66 | + gap: ${p => p.theme.space.md}; |
| 67 | + @media (max-width: ${p => p.theme.breakpoints.sm}) { |
| 68 | + flex-direction: column; |
| 69 | + } |
| 70 | +`; |
| 71 | + |
| 72 | +// New |
| 73 | +import {Flex} from '@sentry/scraps/layout'; |
| 74 | +<Flex direction={{xs: 'column', sm: 'row'}} gap="md"> |
| 75 | +``` |
| 76 | + |
| 77 | +### Rung 2 — `@media` → `@container` (when it can't be a prop) |
| 78 | + |
| 79 | +```tsx |
| 80 | +// Old |
| 81 | +@media (max-width: ${p => p.theme.breakpoints.md}) { ... } |
| 82 | + |
| 83 | +// New — swap at-rule AND scale; md breakpoint (992px) → nearest container token by px |
| 84 | +// is 3xl (1024px), NOT theme.container.md by matching key |
| 85 | +@container (max-width: ${p => p.theme.container['3xl']}) { ... } |
| 86 | +``` |
| 87 | + |
| 88 | +### Rung 3 — `useMedia` (width) → container-scoped JS |
| 89 | + |
| 90 | +Both helpers below read the nearest query container (call from a descendant of one) and re-render as it crosses a breakpoint. A single `max-width` boolean is cleanest as a responsive value; reach for the active key only when you branch on the key itself. |
| 91 | + |
| 92 | +```tsx |
| 93 | +// Old |
| 94 | +const isNarrow = useMedia(`(max-width: ${theme.breakpoints.sm})`); |
| 95 | + |
| 96 | +// New — resolve a responsive boolean against the container, same mobile-first |
| 97 | +// cascade as CSS. A max-width query is "on by default, off past the threshold", |
| 98 | +// so name only the threshold key. Map by pixel value: breakpoints.sm (800px) → |
| 99 | +// nearest container token is xl (768px). |
| 100 | +import {useResponsivePropValue} from '@sentry/scraps/layout'; |
| 101 | + |
| 102 | +const isNarrow = useResponsivePropValue({zero: true, xl: false}); |
| 103 | +// below xl → true, at/above xl → false — one key on each side, nothing to enumerate. |
| 104 | +``` |
| 105 | + |
| 106 | +Reach for `useContainerBreakpoint()` instead only when you branch on the key |
| 107 | +itself (e.g. picking one of several layouts), not a single threshold. It returns |
| 108 | +the container's active key (`'zero'` … `'5xl'`) — don't compare it with |
| 109 | +`=== 'zero'` for a max-width case: that fires only below 320px and drops the |
| 110 | +320–768px range the original query treated as narrow. |
| 111 | + |
| 112 | +## Migration Checklist |
| 113 | + |
| 114 | +Took the lowest rung that fits (above). Then verify the gotchas: |
| 115 | + |
| 116 | +- [ ] Mapped to the `container` token with the nearest pixel value, not the same name — e.g. `breakpoints.sm` → `container.xl`, not `container.sm` |
| 117 | +- [ ] For width read in JS, used `useResponsivePropValue({...})` for a threshold boolean; reserved `useContainerBreakpoint()` for branching on the key — never `=== 'zero'` to mean "narrow" (that's only <320px) |
| 118 | +- [ ] Routed genuine viewport-width cases to `screen:` keys; kept `useMedia` only for non-width media features |
| 119 | +- [ ] Added `container-type` only when a subtree needs its own; used `inline-size` |
| 120 | +- [ ] Confirmed a query-container ancestor exists (`@container` silently no-ops without one) |
| 121 | +- [ ] **Visual check:** resized the element and confirmed identical output flipping at the intended width |
0 commit comments