Style Props
The design scale as typed props: spacing, color, radius and more on every layout primitive.
The idea#
Instead of writing CSS (or utility class strings) for everyday styling, you pass tokens as props:
<Box p="6" bg="subtle" rounded="xl" shadow="sm" maxW="md" />Three things distinguish this from a style attribute:
- Values come from the design scale first:
p="6"is the spacing token6,bg="subtle"is a semantic color role. Your editor autocompletes the tokens, so consistency is the default, not a discipline. - The scale is a vocabulary, not a ceiling. When no token fits, pass the
CSS value itself (
p="37px",w="calc(100vw - 200px)") and it rides the same delivery. See Arbitrary values. - Every prop is responsive. Any value can be a breakpoint map. See Responsive Props.
import { Box, HStack } from "astralis-ui";
export function BoxStyleProps() {
return (
<HStack gap="4" wrap="wrap" justifyContent="center">
<Box bg="brand-subtle" size="16" rounded="md" />
<Box bg="brand-muted" size="16" rounded="lg" />
<Box bg="brand" size="16" rounded="xl" shadow="md" />
<Box bg="transparent" size="16" rounded="xl" border="thick" borderColor="brand" />
<Box bg="inverted" size="16" rounded="full" />
</HStack>
);
}Where style props live#
Box is the host: a polymorphic div exposing the
full set. Every other layout primitive (Flex, Stack, Grid, Center,
Container, AspectRatio, Float) extends Box, so all of them accept all of
these props on top of their own. Typography components (Text, Heading)
carry the relevant subset.
Paint and placement#
Everything else follows one rule:
A component owns how it looks. Its parent owns where it sits and how big it is.
Paint is how something looks on its own: bg, color, borderColor,
rounded, shadow, and the padding inside it. Higher-level components
(Button, Card, Select, Alert…) do not take these. Their appearance is the
design system's job, adjusted through variant, size and colorScheme.
Repainting one from the outside would defeat the variants it ships with.
Placement is how big something is and where it sits inside its parent: width and height, the flex-item props, and margin. A component cannot know these: only the composition can. So every component that can be a child accepts them.
The test, if you're unsure which one you're reaching for:
If I moved this component to a different page, would the prop still be right?
variant="outline" still is. That's paint. w="full" depends entirely on
what's around it. That's placement.
| Prop | Type | Default | Description |
|---|---|---|---|
w · minW · maxW | "0" – "96", "full", "screen", fractions, "xs" – "7xl" | None | How much horizontal room the component takes in its parent. |
h · minH · maxH | "0" – "96", "full", "screen", fractions | None | The same vertically. |
flex · basis · grow · shrink | "1", "auto", "initial", "none" · spacing scale | None | How the component behaves as a flex child: whether it fills, holds, or shrinks. |
order · alignSelf | "first" | "last" | "none" · "start" | "center" | "end" | "stretch" | "baseline" | None | Where it sits among its siblings, overriding the parent's alignment for one child. |
m · mx · my · mt/mb/ml/mr | "0" – "96" (spacing scale) | "auto" | None | Space between the component and its siblings. auto is the layout tool: ml="auto" pushes a flex item to the far edge, mx="auto" centres a fixed-width block. |
Padding is paint but margin is placement, which looks inconsistent until you say it out loud: padding is space inside a component, which its recipe owns; margin is space between it and its siblings, which only the parent can decide.
One name, two meanings#
size is the exception worth knowing. On Box it sets width and height. On
a component with its own scale (Card, Button, Badge), it selects that scale.
They are different props that happen to share a name, so a component with a
recipe size does not inherit Box's.
Container has no scale of its own, so size there used to fall through to
Box's and silently constrain the height. It is now a type error; use maxW.
<Container size="lg"> {/* error: Container has no size */}
<Container maxW="4xl"> {/* what you meant */}The prop families#
| Prop | Type | Default | Description |
|---|---|---|---|
p · px · py · pt/pb/pl/pr | "0" – "96" (spacing scale) | None | Padding: all sides, per axis, or per edge. |
m · mx · my · mt/mb/ml/mr | "0" – "96" (spacing scale) | "auto" | None | Margin: all sides, per axis, or per edge. auto pushes a flex item to the far edge or centres a fixed-width block. |
w · h · size · minW/maxW · minH/maxH | spacing scale | "xs"–"8xl" | fractions | "auto" | "full" | "fit" | "prose" … | None | Sizing. size sets width and height at once. |
bg | "base" | "subtle" | "muted" | "raised" | "inverted" | "{hue}" (solid) | "{hue}-subtle" | "{hue}-muted" | "{hue}-50"–"{hue}-950" | None | Background from semantic roles, palette roles, or raw shades. Transparent by default. base is the page, subtle the thing sitting on it, muted a filled control; raised is for floating layers only. |
color | "base" | "muted" | "subtle" | "inverted" | "{hue}" (label) | "{hue}-solid" | "{hue}-contrast" | None | Text color token; children inherit it. |
border (+ borderT/R/B/L) · borderStyle · borderColor | "normal"–"thickest" · CSS styles · "base" | "subtle" | "muted" | "{hue}" (stroke) | None | Border width, style and color tokens. Per-side widths mirror rounded: borderB is a header rule, borderT a footer one. |
rounded (+ roundedT/R/B/L, roundedTl/Tr/Br/Bl) | "none" | "2xs"–"4xl" | "full" | None | Corner radius: all corners, per side, or per corner. |
display | "block" | "flex" | "grid" | "hidden" | … | None | CSS display. |
position · inset · top/right/bottom/left · zIndex | position tokens + offset scale | None | Positioning. |
shadow | "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "inner" | None | Box shadow token. |
opacity · overflow(X/Y) · cursor · pointerEvents · aspectRatio | token maps | None | Misc utilities, all typed. |
basis · flex · grow · shrink · order · alignSelf | sizing scale · "1" | "auto" | "initial" | "none" · boolean | "0" | "1" · "1"–"12" · "auto" | "start" | "center" | "end" | "stretch" | "baseline" | None | How the element behaves as a child of a flex or grid parent. On Box, so any primitive can size itself without a Flex.Item wrapper. |
container | boolean | false | Makes this element the query container that descendants' @sm–@xl responsive keys measure against, instead of the viewport. |
as | ElementType | "div" | Element or component to render; HTML props follow it type-safely. |
Arbitrary values#
Every value prop (spacing, sizing, gap, radius, offsets, colors, shadow, opacity: everything that isn't a closed keyword set) accepts any CSS value alongside its tokens. A string that isn't a token is delivered through the same custom property the token would have used:
<Box p="37px" /> {/* raw length */}
<Box w="calc(100vw - 200px)" /> {/* calc, clamp, min, max */}
<Box bg="#0ea5e9" borderColor="oklch(0.7 0.1 200)" />
<Box p={{ base: "5vw", md: "4" }} /> {/* mix raw and tokens per breakpoint */}
<Box hover={{ bg: "rebeccapurple" }} /> {/* states take them too */}Two things to know:
- Tokens win. If the string is a token, you get the token's variable:
p="4"is alwaysvar(--astralis-spacing-4), never the literal4. - Units are on you. A bare number like
p="37"isn't valid CSS for a length, so Astralis warns in development and passes it through unchanged. Writep="37px"(or use a token).
Keyword props stay closed. display, alignment, overflow and friends are
finite CSS vocabularies backed by build-time classes. An unknown value there
resolves to nothing, exactly as before.
Escape hatches#
Arbitrary values cover most "outside the scale" needs. For everything else, drop down a level, in this order:
1. style: for dynamic values and properties that aren't props. Value
props deliver through CSS custom properties, and your style always wins
over them:
<Box style={{ transform: `translateX(${offset}px)` }} />
<Box p="4" style={{ paddingTop: "13px" }} /> {/* one-off beats the prop */}Reach for the token variables inside style to stay on the scale:
<Box style={{ gap: "var(--astralis-spacing-1)" }} />2. className: every component forwards it. Astralis's internal
classes are prefixed (astralis:*), so your own utility classes, whatever
generates them, can't collide. Note that Astralis ships utility classes
only for keyword props (display, alignment, overflow, ...); since the
var-channel migration there is no per-value class like astralis:p-4 to
reach for. Value styling belongs to the props or to style.
<Box p="4" className="my-fancy-gradient" />3. Plain CSS: Astralis renders ordinary DOM elements, and every design token is a documented CSS variable, so your stylesheets can target components and stay on the scale:
.sidebar {
padding: var(--astralis-spacing-6);
background: var(--astralis-color-surface-subtle);
}Why tokens?#
Astralis ships precompiled CSS. Value props resolve to a fixed rule per prop that reads a custom property (see Responsive Props for the mechanics), and keyword props resolve to pre-generated classes. A build-time coverage gate verifies every rule, class, and token value has CSS behind it. Because values ride custom properties rather than classes, they don't need to exist at build time (which is what makes arbitrary values free), and everything stays zero-runtime: no style engine executes in the browser.