Latticeguides

Styling with recipes

Style Lattice surfaces with the sx value for one-off theming and createRecipe for reusable variant systems, then attach either to headless primitives.

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: either a plain set of host props, or a function from the current Theme to host props. Use it where a component needs its own look, right there.
  • createRecipe — a reusable variant system, the Roblox-props equivalent of cva on the web. Base styles plus named variants plus compound overrides, resolved to a single 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 also resolve at runtime and read the live Theme, which is what makes a theme toggle work. If your project compiles Tailwind-shaped class names with vela-rbxts, that is a third route with the opposite tradeoff — no runtime cost, no runtime theming — and it composes with these two 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 any of the three into a plain props object — objects pass through, functions are called with the theme, undefined becomes {}.

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, the merge used everywhere in this package: later layers win key-by-key, except that Event and Change handler tables are chained — when two layers bind the same signal, both handlers run in order instead of one clobbering the other. On Box and Text the resolved sx is the later layer, so a themed sx value overrides a direct prop with the same key.

When you need to combine several sx values — 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 the same component needs sizes, tones, and states. createRecipe takes a config of base, variants, defaultVariants, and compoundVariants — each style slot is itself an Sx, so any of them can read the theme — and returns a resolver: (selection, theme) => Partial<Props>.

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 caller’s selection is merged over defaultVariants, then the resolver folds base, each selected variant’s sx, and every compoundVariants entry whose variants keys all match the resolved selection — each layer merged with mergeGuiProps, so later layers win and event tables compose. The result is a plain props object you spread onto an intrinsic or pass through Box:

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 itself), recipes are plain functions: call them in a component with useTheme(), in a test with defaultLightTheme, or anywhere else you have a Theme in hand. Switching themes re-renders the consumers, and every theme-aware slot in the recipe re-resolves automatically.

When to use which

Reach for inline sx when the styling belongs to one place: a screen-specific panel, a spacing tweak, a single themed label. It keeps the style next to the markup and costs nothing to set up. Reach for createRecipe the moment the same visual decisions appear in more than one component, or one component grows an axis of variation — sizes, tones, emphasis levels. Recipes centralize the 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 to a recipe. The two compose, too — spread the recipe output first, then let a caller-supplied sx (via mergeSx or Box) layer overrides on top.

Styling headless primitives

This is where the style package meets the behavior packages. Lattice parts accept asChild, which means they merge their behavior — event handlers, refs, selection flags — onto the element you provide. Give them 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.