Container Queries

NEW
A component that answers to the space it was given rather than to the size of the window — cq, keyed by the same six sizes Tailwind uses.

The width that matters is the container's

A breakpoint asks about the window, which is the wrong question for a card: the same card is wide in a page and narrow in a sidebar, and the viewport says nothing about which. cq asks the element's own container instead. It nests exactly like a breakpoint — the same value shape, the same states, themes and groups inside it — and it fills the same one slot, because a rule lives in exactly one at-rule block.

Two props and one nesting key
JSX
<Flex container>                          {/* container-type: inline-size */}
  <Box cq={{ md: { d: 'row' } }} />       {/* @container (min-width: 28rem) */}
</Flex>

<Flex container="sidebar">                {/* a named container */}
  <Box cq={{ 'sidebar/md': { d: 'row' } }} />
</Flex>

One card, two slots, no JavaScript

The card below is written once. The left slot is 256px wide (width is the ÷4 scale, so 64) and the right one takes whatever the page has left, and the card lays itself out from that — the window never comes into it, so this reads the same in a phone-width column as it does here.

cq on a card
Container queries
This card becomes a row once its container passes 24rem.
Container queries
This card becomes a row once its container passes 24rem.
JSX
<Flex container width={64}>       {/* 256px — below the sm container size */}
  <Flex
    d="column"
    cq={{ sm: { d: 'row', ai: 'center' } }}
    gap={4}
    p={4}
    b={1}
    borderColor="slate-300"
    borderRadius={3}
  >
    <Box width={12} height={12} borderRadius={12} bgColor="indigo-500" />
    <Box flexGrow={1}>
      <Box fontWeight={600}>Container queries</Box>
      <Box fontSize={13} color="slate-500">
        This card becomes a row once its container passes 24rem.
      </Box>
    </Box>
    <Button variant="secondary">Open</Button>
  </Flex>
</Flex>

Drag it

The same card again, in a container the browser lets you resize. Nothing here listens for anything: the styles are two CSS rules and the browser re-evaluates them as the box changes width. One thing a drag does need, though: every Box transitions on --transitionTime, so a resized box would chase the pointer instead of following it — and since a custom property is inherited, vars={{ transitionTime: '0s' }} on the container settles that for the whole subtree.

A container you can drag
Container queries
This card becomes a row once its container passes 24rem.
JSX
<Box
  container
  resize="horizontal"
  overflow="auto"
  vars={{ transitionTime: '0s' }}   // direct manipulation: follow the pointer, do not animate to it
  width={72}
  minWidth={50}
  maxWidth="fit"
  p={2}
>
  {/* the card from above */}
</Box>

Six sizes, and their complements

The scale is Tailwind's @xs@2xl, so a component copied from there queries at the same widths — and deliberately far smaller than the breakpoints, because a card is 400px wide and a viewport is not. Every size has a max form, which is its complement rather than a max-width an epsilon below it: md and maxMd can never both match.

The scale
CSS
xs   20rem / 320px      maxXs   not (min-width: 20rem)
sm   24rem / 384px      maxSm   not (min-width: 24rem)
md   28rem / 448px      maxMd   not (min-width: 28rem)
lg   32rem / 512px      maxLg   not (min-width: 32rem)
xl   36rem / 576px      maxXl   not (min-width: 36rem)
xxl  42rem / 672px      maxXxl  not (min-width: 42rem)

Naming a container, and what a name may be

cq queries the nearest container by default, which is what you want until a card sits inside a card. A name is written on the container and addressed as name/size. Because that name lands in an at-rule prelude, it is validated first: anything that is not a CSS identifier — or that is a word the prelude itself uses, like not — drops the whole block, with no rule and no class name, the way an unmatched prop value does.

name/size
JSX
<Flex container="page" d="column" gap={4}>
  <Flex container="card" p={4}>
    <Box
      cq={{
        md: { fontSize: 16 },              // the nearest container — the card
        'page/xl': { fontSize: 18 },       // the one two levels up
        maxSm: { display: 'none' },        // and the card again, when it is narrow
      }}
    />
  </Flex>
</Flex>

Where it lands in the cascade

A container query is a more local statement than a breakpoint, so it is ranked after every breakpoint and before every user preference — a wide window is no reason to override what the card's own space says, and neither of them is a reason to override motionReduce. Sizes ascend; the max keys descend, so the narrower one wins where two overlap.

The order rules are written in
CSS
/* p={2} md={{ p: 4 }} cq={{ md: { p: 6 } }} motionReduce={{ p: 8 }} */
.a { padding: 0.5rem }
@media (min-width: 768px) { .b { padding: 1rem } }
@container (min-width: 28rem) { .c { padding: 1.5rem } }
@media (prefers-reduced-motion: reduce) { .d { padding: 2rem } }

Becoming a container costs something

container means container-type: inline-size: the element's width no longer depends on its contents, so an inline-size container is laid out first and its children second. That is the point — and the reason not to declare it on everything. containerType="size" queries both axes and needs the element to have a block size of its own; containerName is the longhand, for when you want a name without the shorthand's type.

The three props
JSX
<Flex d="column" gap={4}>
  <Box container />                                   {/* container-type: inline-size */}
  <Box container="sidebar" />                         {/* container: sidebar / inline-size */}
  <Box containerName="panel" containerType="size" />  {/* both axes, named */}
  <Box container="sidebar" containerType="normal" />  {/* and off again */}
</Flex>

One block per rule

A rule sits in one at-rule block, so cq does not nest inside a breakpoint and a breakpoint does not nest inside cq — the types refuse both, the way they already refuse a breakpoint inside a breakpoint. Everything else nests in either direction: pseudo-classes, groups, themes, the state variants, a pseudo-element and startingStyle.

What nests inside a query
JSX
<Box
  cq={{
    md: {
      hover: { color: 'indigo-500' },
      theme: { dark: { bgColor: 'slate-800' } },
      dataAttr: { 'state=open': { height: 40 } },
      before: { content: 'Wide' },
      startingStyle: { opacity: 0 },
    },
  }}
/>