Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/getsentry-migrate-container-queries/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-02 | ✗→✓ | ▲ Improved | 96% | 0% |
| case-04 | ✗→✓ | ▲ Improved | 7% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-06 | ✗→✓ | ▲ Improved | 140% | 0% |
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.
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 |
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.
screen: keys, not useMediaWhen 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.
screen: props are migration candidatesscreen:-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.
useMedia only for non-width media featuresWidth — 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.
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:
inline-size (width only). size also queries height, which collapses content unless height is set elsewhere.containerType={hasParentQueryContainer ? 'normal' : 'inline-size'} via useHasContainerQuery() (see components/core/breadcrumbList/breadcrumbList.tsx).@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">
@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']}) { ... }
useMedia (width) → container-scoped JSBoth 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.
Took the lowest rung that fits (above). Then verify the gotchas:
container token with the nearest pixel value, not the same name — e.g. breakpoints.sm → container.xl, not container.smuseResponsivePropValue({...}) for a threshold boolean; reserved useContainerBreakpoint() for branching on the key — never === 'zero' to mean "narrow" (that's only <320px)screen: keys; kept useMedia only for non-width media featuresscreen:-prefixed layout props — migrated component-width cases to bare container keys and kept screen: only for genuine viewport-width behaviorcontainer-type only when a subtree needs its own; used inline-size@container silently no-ops without one)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→pass | 19,742 | 12,481 | -37% | 1 | 1 | 0% | 2,845 | 4,032 | +42% | 0 | 0 | — |
case-02 | fail→pass | 16,395 | 13,686 | -17% | 1 | 1 | 0% | 2,103 | 4,120 | +96% | 0 | 0 | — |
case-03 | fail→fail | 18,450 | 13,044 | -29% | 1 | 1 | 0% | 2,541 | 4,166 | +64% | 0 | 0 | — |
case-04 | fail→pass | 24,554 | 10,423 | -58% | 1 | 1 | 0% | 3,289 | 3,520 | +7% | 0 | 0 | — |
case-05 | fail→pass | 18,617 | 17,819 | -4% | 1 | 1 | 0% | 2,495 | 3,814 | +53% | 0 | 0 | — |
case-06 | fail→pass | 13,038 | 11,504 | -12% | 1 | 1 | 0% | 1,602 | 3,837 | +140% | 0 | 0 | — |
case-07 | fail→pass | 17,225 | 11,222 | -35% | 1 | 1 | 0% | 2,453 | 3,373 | +38% | 0 | 0 | — |
case-13 | pass→pass | 19,091 | 6,617 | -65% | 1 | 1 | 0% | 3,449 | 3,621 | +5% | 0 | 0 | — |
case-08 | fail→pass | 30,343 | 9,785 | -68% | 1 | 1 | 0% | 5,080 | 3,355 | -34% | 0 | 0 | — |
case-09 | fail→pass | 19,182 | 10,824 | -44% | 1 | 1 | 0% | 2,441 | 3,253 | +33% | 0 | 0 | — |
case-10 | fail→pass | 25,218 | 4,810 | -81% | 1 | 1 | 0% | 2,973 | 3,113 | +5% | 0 | 0 | — |
case-11 | fail→pass | 14,712 | 4,076 | -72% | 1 | 1 | 0% | 2,745 | 3,209 | +17% | 0 | 0 | — |
case-12 | fail→pass | 4,889 | 15,644 | +220% | 1 | 1 | 0% | 975 | 4,550 | +367% | 0 | 0 | — |
case-14 | pass→pass | 8,699 | 4,931 | -43% | 1 | 1 | 0% | 1,405 | 3,195 | +127% | 0 | 0 | — |
case-15 | fail→pass | 18,849 | 7,865 | -58% | 1 | 1 | 0% | 2,546 | 3,846 | +51% | 0 | 0 | — |
case-16 | pass→pass | 12,581 | 10,545 | -16% | 1 | 1 | 0% | 2,072 | 3,482 | +68% | 0 | 0 | — |
case-17 | fail→pass | 11,835 | 23,689 | +100% | 1 | 1 | 0% | 1,187 | 3,031 | +155% | 0 | 0 | — |
case-18 | fail→pass | 13,066 | 3,076 | -76% | 1 | 1 | 0% | 2,475 | 2,981 | +20% | 0 | 0 | — |
case-19 | fail→pass | 11,445 | 6,824 | -40% | 1 | 1 | 0% | 1,915 | 3,701 | +93% | 0 | 0 | — |
case-20 | fail→pass | 14,733 | 4,282 | -71% | 1 | 1 | 0% | 1,796 | 3,295 | +83% | 0 | 0 | — |
case-21 | fail→fail | 16,542 | 5,348 | -68% | 1 | 1 | 0% | 1,835 | 3,287 | +79% | 0 | 0 | — |
case-22 | fail→pass | 17,587 | 3,836 | -78% | 1 | 1 | 0% | 1,750 | 3,142 | +80% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +77 percentage points is the difference between those two pass rates over the 22 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
| Model | Method | Date | Lift |
|---|---|---|---|
| gemini-3.6-flash | verified | 8/2/2026 | +77% |
Other measured skills in the registry, with their headline benchmark lift.