Lattice primitives are headless — they give you behavior and leave the pixels to you. @lattice-ui/react-style is the package that fills the visual half, and it gives you exactly two styling tools:
sx— a one-off style value: plain host props, or a function from the currentThemeto host props.createRecipe— a reusable variant system, the Roblox-props equivalent ofcva. Base styles plus named variants plus compound overrides, resolved to one props object you spread onto a host.
Both produce ordinary Roblox instance props in the end. The difference is where the styling decision lives: inline at the call site, or centralized in a recipe your whole design system shares.
Both resolve at runtime and read the live Theme, which is what makes a theme toggle work.
vela-rbxts is a third route with the opposite tradeoff: no runtime cost, and no
runtime theming. It composes with these rather than replacing them. See
styling with Vela.
The sx value
An Sx<Props> is deliberately small:
type Sx<Props> = Partial<Props> | ((theme: Theme) => Partial<Props>) | undefined;A static props object when the style is fixed, a function of the theme when it should track tokens,
undefined when there is nothing to say. resolveSx(sx, theme) collapses all three into a plain
props object.
The Box and Text primitives accept an sx prop and resolve it against the active ThemeProvider theme for you. Box renders a frame, Text renders a textlabel, and both forward every other prop straight to the host instance:
import { React } from "@rbxts/react";import { Box, Text } from "@lattice-ui/react-style";
export function Card(props: { title: string }) { return ( <Box Size={UDim2.fromOffset(280, 96)} sx={(theme) => ({ BackgroundColor3: theme.colors.surface, BorderSizePixel: 0, })} > <Text Position={UDim2.fromOffset(16, 16)} Text={props.title} sx={(theme) => ({ BackgroundTransparency: 1, TextColor3: theme.colors.textPrimary, TextSize: theme.typography.titleMd.textSize, Font: theme.typography.titleMd.font, })} /> </Box> );}Direct host props and the resolved sx are combined with mergeGuiProps. Later layers win
key-by-key, except that Event and Change handler tables are chained, so both run in order.
On Box and Text the resolved sx is the later layer, so it overrides a direct prop with the
same key.
To combine several sx values, such as a shared base plus a caller override, use mergeSx. It
returns a single theme-resolving function that folds its arguments left to right, later entries
winning:
import { mergeSx, type Sx } from "@lattice-ui/react-style";
const panelBase: Sx<Record<string, unknown>> = (theme) => ({ BackgroundColor3: theme.colors.surfaceElevated, BorderSizePixel: 0,});
export function Panel(props: { sx?: Sx<Record<string, unknown>> }) { return <Box sx={mergeSx(panelBase, props.sx)} Size={UDim2.fromOffset(320, 200)} />;}createRecipe: variant systems
Inline sx scales badly once a component needs sizes, tones and states. createRecipe takes
base, variants, defaultVariants and compoundVariants, and returns
(selection, theme) => Partial<Props>. Each slot is itself an Sx, so any of them can read the
theme.
import { createRecipe } from "@lattice-ui/react-style";
export const buttonRecipe = createRecipe({ base: (theme) => ({ AutoButtonColor: false, BackgroundColor3: theme.colors.surface, BorderSizePixel: 0, TextColor3: theme.colors.textPrimary, Font: theme.typography.labelSm.font, }), variants: { size: { sm: { Size: UDim2.fromOffset(96, 28), TextSize: 14 }, md: { Size: UDim2.fromOffset(128, 36), TextSize: 16 }, }, tone: { neutral: {}, accent: (theme) => ({ BackgroundColor3: theme.colors.accent, TextColor3: theme.colors.accentContrast, }), danger: (theme) => ({ BackgroundColor3: theme.colors.danger, TextColor3: theme.colors.dangerContrast, }), }, }, defaultVariants: { size: "md", tone: "neutral" }, compoundVariants: [ // Small danger buttons get bolder text so they stay legible. { variants: { size: "sm", tone: "danger" }, sx: (theme) => ({ Font: theme.typography.titleMd.font }), }, ],});Resolution is deterministic layering. The selection merges over defaultVariants, then the resolver
folds base, each selected variant’s sx, and every matching compoundVariants entry. Each layer
merges with mergeGuiProps, so later layers win and event tables compose.
import { React } from "@rbxts/react";import { useTheme } from "@lattice-ui/react-style";import { buttonRecipe } from "./button.recipe";
export function Button(props: { text: string; size?: "sm" | "md"; tone?: "neutral" | "accent" | "danger"; onActivated?: () => void;}) { const { theme } = useTheme(); const styleProps = buttonRecipe({ size: props.size, tone: props.tone }, theme);
return ( <textbutton {...styleProps} Text={props.text} Event={{ Activated: () => props.onActivated?.() }} /> );}Because the resolver takes the theme as an argument rather than reading context, recipes are plain
functions. Call them with useTheme() in a component, defaultLightTheme in a test, or any Theme
you have. Switching themes re-renders consumers and re-resolves every theme-aware slot.
Passing undefined is valid — the resolver falls back to defaultVariants per group. A group with neither a selected nor a default value is skipped, so make sure every group you rely on has one or is always passed.
When to use which
Reach for inline sx when the styling belongs to one place — it keeps the style next to the markup
and costs nothing to set up. Reach for createRecipe the moment the same decisions appear in more
than one component, or one grows an axis of variation. Recipes centralize token usage, make the
variant space explicit in types, and give you compoundVariants for the combinations that need
special handling. A practical rule: the second time you copy an sx between files, promote it. The
two compose — spread the recipe output first, then let a caller-supplied sx layer over it.
Styling headless primitives
This is where the style package meets the behavior packages. Give an asChild part an element
carrying recipe output and you have a fully styled, fully wired control:
import { React } from "@rbxts/react";import { Checkbox } from "@lattice-ui/react-checkbox";import { createRecipe, useTheme } from "@lattice-ui/react-style";
const checkboxRecipe = createRecipe({ base: (theme) => ({ AutoButtonColor: false, BackgroundColor3: theme.colors.surface, BorderSizePixel: 0, TextColor3: theme.colors.textPrimary, TextSize: theme.typography.labelSm.textSize, }), variants: { size: { sm: { Size: UDim2.fromOffset(120, 28) }, md: { Size: UDim2.fromOffset(160, 36) }, }, }, defaultVariants: { size: "md" },});
export function StyledCheckbox(props: { label: string; size?: "sm" | "md" }) { const { theme } = useTheme();
return ( <Checkbox.Root asChild> <textbutton {...checkboxRecipe({ size: props.size }, theme)} Text={props.label} /> </Checkbox.Root> );}Checkbox.Root composes Active, Selectable, its Activated toggle handler and its motion ref
onto your textbutton. The recipe supplies everything visual. Your styling and the primitive’s
behavior never fight, because Slot chains event tables instead of replacing them.
Spread recipe output onto a real intrinsic when the part is interactive. Box renders a frame, which has no Activated signal, and does not forward a ref to its instance. Wrapping an interactive part’s asChild child in Box silently drops both the activation and the primitive’s ref.
For non-interactive parts the pairing runs the other way. Box and Text themselves support
asChild, so you can push a resolved sx onto an element another component renders.
<Box asChild sx={...}> around a single child merges the themed props onto it via Slot, instead
of adding a wrapper frame.