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.
import { Box, Text } from "astralis-ui";
export function BoxResponsive() {
return (
<Box
bg="green-subtle"
rounded={{ base: "md", md: "2xl" }}
p={{ base: "4", md: "8", lg: "12" }}
>
<Text size="sm" color="muted">
Resize the window: padding and radius step up at md and lg.
</Text>
</Box>
);
}Breakpoints#
| Key | Min-width | Typical device |
|---|---|---|
base | None | Everything (mobile first) |
sm | 40rem / 640px | Large phones |
md | 48rem / 768px | Tablets |
lg | 64rem / 1024px | Laptops |
xl | 80rem / 1280px | Desktops |
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 thecontainerprop (any layout primitive takes it). With no container ancestor,@values simply never fire. containerusescontainer-type: inline-size. The element sizes from its context as usual, but note it no longer contributes its intrinsic width tomax-contentancestors.
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
styleare 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.