Latticeguides

Styling with recipes

The `sx` value for one-off theming and `createRecipe` for reusable variant systems.

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 current Theme to host props.
  • createRecipe — a reusable variant system, the Roblox-props equivalent of cva. 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:

Card.tsx
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:

Composing sx values
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.

button.recipe.ts
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.

Button.tsx
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.

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:

StyledCheckbox.tsx
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.

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.