Responsive Props

Every style prop accepts a breakpoint map: mobile-first, precompiled, zero runtime cost.

The syntax#

Anywhere a style prop takes a token, it also takes an object keyed by breakpoint:

<Box p="4" />                                {/* scalar */}
<Box p={{ base: "2", md: "4", xl: "8" }} />  {/* responsive map */}

Resize the window: padding and radius step up at md and lg.

Breakpoints#

KeyMin-widthTypical device
baseNoneEverything (mobile first)
sm40rem / 640pxLarge phones
md48rem / 768pxTablets
lg64rem / 1024pxLaptops
xl80rem / 1280pxDesktops

Mobile-first means each key applies from that width up. base is your mobile value; md overrides it from 768px onward and keeps applying at lg and xl unless you override again. You only write the breakpoints where something changes:

{/* column on phones, row from tablets up, nothing else needed */}
<Flex direction={{ base: "column", md: "row" }} />

Omitting base is fine too: the prop's normal default fills in below your first breakpoint.

What's responsive#

All layout style props (spacing, sizing, colors, radius, display, position: everything on Style Props) plus the layout-shaping variant props of the primitives:

<Grid columns={{ base: "1", sm: "2", lg: "4" }} gap={{ base: "3", lg: "6" }}>
<Stack direction={{ base: "vertical", md: "horizontal" }}>
<Text size={{ base: "sm", md: "md" }}>
<Separator orientation={{ base: "horizontal", md: "vertical" }} />

The common recipes:

{/* hide on mobile, show from md */}
<Box display={{ base: "hidden", md: "block" }} />

{/* full-width mobile, fixed sidebar on desktop */}
<Box w={{ base: "full", md: "64" }} />

{/* tighter type and padding on small screens */}
<Heading size={{ base: "lg", md: "2xl" }} />

Container queries#

Every responsive key has a container-query twin, written with an @ prefix: @sm, @md, @lg, @xl. Same names, same widths, but measured against the nearest ancestor marked container instead of the browser window.

<Box container>
  <Card size={{ base: "sm", "@md": "lg" }} />
</Box>

The card is lg when the Box is at least 48rem wide. Drop the same card into a narrow sidebar and it renders sm, on the same screen, with no extra code. That is the promise viewport breakpoints can't keep: a component styled by the space it actually has, wherever it lands.

Everything responsive works with @ keys (keyword props, value props, arbitrary values), and viewport and container keys mix freely in one map:

<Flex container direction="column" gap="2">
  {/* hidden on phones (viewport), row layout once the panel is wide (container) */}
  <Flex display={{ base: "hidden", sm: "flex" }} direction={{ base: "column", "@md": "row" }} />
</Flex>

Two rules to remember:

  • Something must be the container. @ keys measure the nearest ancestor with the container prop (any layout primitive takes it). With no container ancestor, @ values simply never fire.
  • container uses container-type: inline-size. The element sizes from its context as usual, but note it no longer contributes its intrinsic width to max-content ancestors.

How it works (and why it's fast)#

Nothing is computed in the browser. A value-bearing prop resolves to one fixed class per breakpoint plus a CSS custom property carrying the value:

<!-- p={{ base: "2", md: "4" }} renders as -->
<div
  class="astralis-p astralis-p-md"
  style="--astralis-p: var(--astralis-spacing-2); --astralis-p-md: var(--astralis-spacing-4)"
/>

The classes already exist in the stylesheet you imported: .astralis-p reads var(--astralis-p), and .astralis-p-md does the same inside a min-width: 48rem media query. The class says where the value lands; the variable says what it is. Because the value travels as a token variable rather than being baked into a class name, the stylesheet stays small no matter how many values exist, and theming keeps cascading through the same tokens.

Keyword props (display, position, alignment, ...) are small closed sets and resolve to pre-generated classes per breakpoint instead (astralis:hidden astralis:md:flex). A build gate verifies every class (fixed rules and keyword variants alike) landed in the compiled CSS. From there it's ordinary media queries, handled natively by the browser.

Two practical consequences:

  • No resize listeners, no JS at breakpoints, no flash: server-rendered HTML is responsive before hydration (custom properties in style are plain HTML attributes).
  • Values are unbounded. Because only the class must exist at build time, a breakpoint entry can be a scale token or any CSS value: p={{ base: "5vw", md: "4" }} mixes both. See Arbitrary values.