This guide covers the rules and patterns for authoring styles in packages/ui.
The system uses vanilla-extract (VE) with CSS @layer
ordering, shared recipes, and composition over inheritance.
Every CSS property on an element should have exactly one authoritative source of truth. When two rules set the same property at the same specificity, source order decides — which is fragile and hard to reason about.
Anti-pattern (two owners fighting):
// primitive: always sets padding 0.25rem
export const comboboxList = style({ padding: '0.25rem' });
// consumer: also sets padding → source-order roulette
export const list = style({ padding: '0.25rem' });Correct (single owner):
// primitive owns padding; consumer just composes it
// consumer delegates to primitive, no override
<ComboboxList> {/* no extra className needed */}Express component state through data-* attributes handled inside the owning
style() or recipe(), not by adding specificity from the outside.
Anti-pattern (state via higher specificity):
// parent component pushing state into child via specificity escalation
globalStyle(`${someParent} ${childClass}`, { padding: 0 });Correct (state-owned selector inside the primitive):
export const comboboxList = style({
padding: '0.25rem',
selectors: {
'&[data-empty]': { padding: 0 }, // library-owned state drives own style
},
});globalStyle with a multi-segment selector that crosses a component boundary
(e.g. ${parentClass} [data-slot="child"]) creates invisible coupling.
If the child's class changes, the parent silently breaks.
Anti-pattern (cross-boundary globalStyle):
// combobox.css.ts reaching into input-group internals
globalStyle(`${comboboxContent} [data-slot="input-group"]`, {
borderRadius: 0,
boxShadow: 'none',
});Correct (variant prop on the child):
// input-group.css.ts adds an "embedded" variant
export const inputGroup = recipe({
variants: {
variant: {
embedded: { borderRadius: 0, boxShadow: 'none' },
},
},
});
// consumer passes the variant explicitly
<InputGroup variant="embedded" />Reuse styles by composing style() arrays rather than inheriting through
class hierarchies or overrides.
// compose shared base into component-specific style
export const menuItem = style([
menuItemBase(), // shared structural recipe
{
selectors: { '&:focus': { backgroundColor: vars.surfaceHover } },
},
]);VE's style([...]) merges multiple style objects/classes into one atomic class at
build time. The recipe() base also accepts an array:
recipe({ base: [sharedBase, { componentSpecific: '...' }] })Use the shared helpers from @styles/effects/svg-helpers.css instead of hand-rolling
globalStyle for SVG sizing.
| Helper | Effect |
|---|---|
svgContainer |
svg { pointer-events: none; flex-shrink: 0 } |
svgDefaultSize |
svg:not([class*='size-']) { width: 1rem; height: 1rem } |
svgSmSize |
svg:not([class*='size-']) { width: 0.75rem; height: 0.75rem } |
svgTextSize |
same as default (alias for inline text contexts) |
export const menuItem = style([svgContainer, svgDefaultSize, { /* ... */ }]);@styles/recipes/menu-item.css — structural recipe for all list item rows
(DropdownMenu, Select, Combobox, ComboboxPopup).
import { menuItemBase } from '@styles/recipes/menu-item.css';
export const myItem = style([
menuItemBase({ trailingIndicator: true, fullWidth: true }),
{ selectors: { '&:focus': { backgroundColor: vars.surfaceHover } } },
]);Variants: trailingIndicator, fullWidth, inset, muted.
@styles/recipes/popup-surface.css — base style for floating popup containers with
the animation keyframe selectors and visual properties already wired up.
import { popupSurface, popupShadowMd } from '@styles/recipes/popup-surface.css';
export const myMenu = style([
popupSurface,
popupShadowMd,
{ minWidth: '12rem', padding: '0.25rem' },
]);Shadow variants: popupShadowSm (tooltips, comboboxes), popupShadowMd (menus, selects).
InputGroup accepts a variant prop:
default— standalone field with border, shadow, and focus ringembedded— bottom-border-only divider for use inside popup containers
// ComboboxInput uses "embedded" automatically
<InputGroup variant="embedded" />Input accepts a bare boolean that strips its standalone border/shadow/focus ring.
InputGroupInput passes bare automatically; consumers should not need it directly.
The layer order is: reset < tokens < base < recipes < utilities.
reset/base—globalStylerules inreset.css.tsandbase.css.tsrecipes— componentstyle()andrecipe()output (target destination)utilities—sx()sprinkles; always overrides component styles
Migration path: To place a style in the recipes layer, wrap properties inside
'@layer': { recipes: { ... } }:
export const foo = style({
'@layer': {
recipes: {
color: vars.foreground,
selectors: { '&:hover': { backgroundColor: vars.surfaceHover } },
},
},
});Important: Unlayered styles always beat ALL layered styles. The migration must be coordinated — mixing layered and unlayered styles for the same property on the same element will make the unlayered one always win regardless of intent. Migrate an entire property-ownership group at once.
If a consumer genuinely needs to override a component style (rare), use the
utilities layer rather than adding specificity:
// Correct: opt into utilities layer to predictably win
export const myOverride = style({
'@layer': {
utilities: { padding: '0.5rem' },
},
});Avoid using !important. If you find yourself reaching for it, the owning
component should expose a variant or data-* hook instead.
- One style definition owns each property on the element
- Interactive states expressed as
data-*-keyed selectors inside the owning style - No
globalStylewith multi-segment selectors crossing component boundaries - SVG sizing via
svgContainer/svgDefaultSize/svgSmSizecomposition - Popup containers compose
popupSurface+ apopupShadow* - List-item rows compose
menuItemBase() - InputGroup inside a popup uses
variant="embedded" - No
!important— expose a variant or compound variant instead