# 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.

Source: https://docs.astra-void.xyz/lattice-ui/guides/styling-with-recipes/

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](https://docs.astra-void.xyz/vela-rbxts/index.md), 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](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md).

## The sx value

An `Sx<Props>` is deliberately small:

```ts
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:

```tsx title="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:

```tsx title="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>`.

```tsx title="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`:

```tsx title="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.

> **Omitted variants resolve through defaultVariants**
>
> Passing `undefined` (or an empty selection) is valid — the resolver falls back to `defaultVariants` per group. A group with neither a selected nor a default value is simply skipped, so make sure every group you rely on has a default or is always passed.

## 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:

```tsx title="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.

> **Interactive parts need a button-class host**
>
> Spread recipe output onto a real intrinsic (`textbutton`, `imagebutton`) when the part is interactive. A `Box` renders a `frame`, which has no `Activated` signal, and `Box` is a function component that does not forward a ref to its instance — so wrapping an interactive part's `asChild` child in `Box` would silently drop both the activation behavior 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`.

## Related

- [Theming](https://docs.astra-void.xyz/lattice-ui/guides/theming.md)
- [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md)
- [Style reference](https://docs.astra-void.xyz/lattice-ui/reference/style.md)
- [Checkbox](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md)
