Guide for migrating viewport media queries (@media, useMedia, and screen:-prefixed responsive props) to container queries in Sentry's frontend. Use when migrating responsive layout to container queries, replacing @media/useMedia/screen: breakpoints, refactoring styled responsive components to Container/Flex/Grid primitives, or working on the DE container-query migration.
Installs into .claude/skills of the current project.
Are you the author of Migrate Container Queries?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/getsentry-migrate-container-queries)
---
name: migrate-container-queries
description: "Guide for migrating viewport media queries (@media, useMedia, and screen:-prefixed responsive props) to container queries in Sentry's frontend. Use when migrating responsive layout to container queries, replacing @media/useMedia/screen: breakpoints, refactoring styled responsive components to Container/Flex/Grid primitives, or working on the DE container-query migration."
---
# Container Query Migration Guide
Migrate viewport-based responsive logic (`@media`, `useMedia`, and `screen:`-prefixed responsive props) to container queries so components respond to their own available space instead of the raw viewport.
> **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.
## Approach: refactor first, swap second
Stop at the first rung that fits. Prefer replacing hand-rolled CSS with primitives over a mechanical token swap.
| Rung | When | Do |
| ---------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| 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'}}`) |
| 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.*` |
| 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 |
| 4. Leave as `useMedia` | Genuine media feature, not width | Do nothing — these do not migrate |
## ⚠️ Convert to the nearest container scale
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.
`theme.breakpoints` (viewport / `@media`), base `2xs`:
| `2xs` | `xs` | `sm` | `md` | `lg` | `xl` | `2xl` |
| ----- | ----- | ----- | ----- | ------ | ------ | ------ |
| 0px | 500px | 800px | 992px | 1200px | 1440px | 2560px |
`theme.container` (container / `@container`), base `zero`:
| `zero` | `3xs` | `2xs` | `xs` | `sm` | `md` | `lg` | `xl` | `2xl` | `3xl` | `4xl` | `5xl` |
| ------ | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ----- | ------ | ------ | ------ |
| 0px | 320px | 384px | 448px | 512px | 576px | 640px | 768px | 896px | 1024px | 1152px | 1280px |
**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.
## Genuine viewport width → `screen:` keys, not `useMedia`
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.
## Existing `screen:` props are migration candidates
`screen:`-prefixed responsive props compile to viewport media queries. Audit them in the same pass as `@media` and `useMedia`. If the layout responds to the component's available width, remove the `screen:` prefix and use bare container keys. Map the old viewport breakpoint by pixel value, and add an explicit container base when the narrow layout differs:
```tsx
// Old — viewport width drives a component layout
<Grid columns={{'screen:2xs': '1fr', 'screen:sm': 'auto 1fr auto auto'}} />
// New — component width drives the layout; screen sm (800px) → container xl (768px)
<Grid columns={{zero: '1fr', xl: 'auto 1fr auto auto'}} />
```
Keep the `screen:` key only when the window itself is the intended source of truth. `screen:` is not a container-query migration target just because it is already a responsive prop.
## Keep `useMedia` only for non-width media features
Width — container or viewport — has a prop/hook path above. Leave `useMedia` in place only for:
`prefers-color-scheme`, `prefers-reduced-motion`, `hover`, `pointer`, `max-height` / height-based, `resolution`, `print`.
## container-type: only when no query container is in scope
**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:
- Use `inline-size` (width only). `size` also queries height, which collapses content unless height is set elsewhere.
- 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`).
## Examples
### Rung 1 — styled `@media` → primitive props (preferred)
```tsx
// Old — delete the styled component
const Row = styled('div')`
display: flex;
flex-direction: row;
gap: ${p => p.theme.space.md};
@media (max-width: ${p => p.theme.breakpoints.sm}) {
flex-direction: column;
}
`;
// New
import {Flex} from '@sentry/scraps/layout';
<Flex direction={{xs: 'column', sm: 'row'}} gap="md">
```
### Rung 2 — `@media` → `@container` (when it can't be a prop)
```tsx
// Old
@media (max-width: ${p => p.theme.breakpoints.md}) { ... }
// New — swap at-rule AND scale; md breakpoint (992px) → nearest container token by px
// is 3xl (1024px), NOT theme.container.md by matching key
@container (max-width: ${p => p.theme.container['3xl']}) { ... }
```
### Rung 3 — `useMedia` (width) → container-scoped JS
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.
```tsx
// Old
const isNarrow = useMedia(`(max-width: ${theme.breakpoints.sm})`);
// New — resolve a responsive boolean against the container, same mobile-first
// cascade as CSS. A max-width query is "on by default, off past the threshold",
// so name only the threshold key. Map by pixel value: breakpoints.sm (800px) →
// nearest container token is xl (768px).
import {useResponsivePropValue} from '@sentry/scraps/layout';
const isNarrow = useResponsivePropValue({zero: true, xl: false});
// below xl → true, at/above xl → false — one key on each side, nothing to enumerate.
```
Reach for `useContainerBreakpoint()` instead only when you branch on the key
itself (e.g. picking one of several layouts), not a single threshold. It returns
the container's active key (`'zero'` … `'5xl'`) — don't compare it with
`=== 'zero'` for a max-width case: that fires only below 320px and drops the
320–768px range the original query treated as narrow.
## Migration Checklist
Took the lowest rung that fits (above). Then verify the gotchas:
- [ ] Mapped to the `container` token with the nearest pixel value, not the same name — e.g. `breakpoints.sm` → `container.xl`, not `container.sm`
- [ ] 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)
- [ ] Routed genuine viewport-width cases to `screen:` keys; kept `useMedia` only for non-width media features
- [ ] Audited existing `screen:`-prefixed layout props — migrated component-width cases to bare container keys and kept `screen:` only for genuine viewport-width behavior
- [ ] Added `container-type` only when a subtree needs its own; used `inline-size`
- [ ] Confirmed a query-container ancestor exists (`@container` silently no-ops without one)
- [ ] **Visual check:** resized the element and confirmed identical output flipping at the intended width