@lattice-ui/react-style gives you tokens. @lattice-ui/react-system gives them somewhere to live.
One provider owns theme and density, and a density system rescales every spacing, radius and type
token globally. Surface tones cover elevation-consistent panels, and layout primitives turn
Roblox layout instances into token-driven components. The
System reference has the full prop tables.
One provider for theme and density
SystemProvider sits at the root. It owns the base theme, wraps a DensityProvider, and
republishes a density-resolved theme through ThemeProvider. Every useTheme consumer below sees
the scaled theme without doing anything.
import React from "@rbxts/react";import { defaultDarkTheme } from "@lattice-ui/react-style";import { SystemProvider } from "@lattice-ui/react-system";
export function App() { return ( <SystemProvider defaultTheme={defaultDarkTheme} defaultDensity="comfortable"> {/* Everything below reads the density-resolved theme. */} </SystemProvider> );}Theme and density are each independently controllable in the standard trio shape: theme/defaultTheme/onThemeChange and density/defaultDensity/onDensityChange. Read and write the pair from anywhere below with useSystemTheme:
import React from "@rbxts/react";import { useSystemTheme } from "@lattice-ui/react-system";
export function DensitySetting() { const { density, setDensity } = useSystemTheme();
return ( <textbutton Text={`Density: ${density}`} Size={UDim2.fromOffset(180, 32)} Event={{ Activated: () => { setDensity(density === "comfortable" ? "compact" : "comfortable"); }, }} /> );}useSystemTheme also returns the resolved theme, the pre-density baseTheme, and setBaseTheme. Writes always target the base theme — the resolved theme is derived, never written.
Density
Density is a single token that answers “how tight should this UI be?” once, instead of
per-component. The options are "compact", "comfortable" and "spacious". applyDensity scales
the whole token scale:
| Token | Space | Radius | Text size |
|---|---|---|---|
compact | ×0.85 | ×0.9 | ×0.92 |
comfortable | ×1 | ×1 | ×1 |
spacious | ×1.15 | ×1.1 | ×1.08 |
Results are rounded and clamped: spacing and radius never go negative, text sizes never drop below
10, and colours pass through untouched. Because the layout primitives resolve gaps and padding
through the theme’s space scale, one density change reflows everything built on tokens.
DensityProvider is the layer SystemProvider uses internally, and you can nest one to re-density a subtree. That makes side-by-side comparisons trivial — the same settings panel, two densities:
import React from "@rbxts/react";import { DensityProvider, Row } from "@lattice-ui/react-system";import { SettingsPanel } from "./settings-panel";
export function DensityPreview() { return ( <Row gap={16} autoSize={true}> <DensityProvider density="compact"> <SettingsPanel /> </DensityProvider> <DensityProvider density="spacious"> <SettingsPanel /> </DensityProvider> </Row> );}Both panels render from identical code. Only the resolved token values differ. This is also the honest way to preview a density setting before the player commits to it.
applyDensity is a pure Theme → Theme transform that never creates or resizes instances. Anything hard-coded in raw pixels is invisible to density, so prefer tokens wherever a value should breathe with the setting.
Surfaces
Panels need consistent backgrounds and borders, and eyeballing colors per-panel drifts fast. System
ships four surface tones, and two ways to apply them. The tones are "surface" (default),
"elevated", "sunken" and "overlay".
The Surface primitive renders a decorated frame: the tone’s background, a uicorner from the
theme radius, and a themed uistroke border. The overlay tone skips decoration and renders a
translucent fill instead.
import React from "@rbxts/react";import { Stack, Surface } from "@lattice-ui/react-system";
export function QuestPanel() { return ( <Surface tone="elevated" Size={UDim2.fromOffset(280, 0)} AutomaticSize={Enum.AutomaticSize.Y}> <Stack gap={8} padding={16} autoSize={true}> <textlabel Text="Daily quests" Size={new UDim2(1, 0, 0, 20)} BackgroundTransparency={1} /> {/* quest rows */} </Stack> </Surface> );}When you only want the tone’s props on an element you already have, surface() returns an sx.
It resolves to background, border colour and border size for the token:
import { surface, Stack } from "@lattice-ui/react-system";
<Stack gap={8} padding={12} sx={surface("sunken")} Size={new UDim2(1, 0, 0, 160)} />;Reach for Surface for the fully decorated panel look and surface() when styling an existing
host. Either way the tone names carry the elevation story: sunken recedes, surface rests,
elevated floats, overlay dims what is behind it.
Layout primitives
Stack, Row and Grid wrap the layout instances in components whose spacing props accept space
tokens or raw pixels. gap={8} means the theme’s 8 token, which compact density might resolve
to 7. Off-scale numbers pass through as pixels. All three render a transparent frame, sort by
LayoutOrder, and accept the same padding shorthands.
Stack is vertical by default. align sets the cross axis and justify the main axis, and the two
swap when direction flips. A HUD column:
import React from "@rbxts/react";import { Stack } from "@lattice-ui/react-system";
export function HudColumn() { return ( <Stack gap={12} align="end" padding={16} autoSize={true} AnchorPoint={new Vector2(1, 0)} Position={new UDim2(1, 0, 0, 0)} > <HealthBar /> <ManaBar /> <BuffList /> </Stack> );}Row is Stack with direction fixed to horizontal — the obvious fit for a button row:
import React from "@rbxts/react";import { Row } from "@lattice-ui/react-system";
export function ConfirmButtons(props: { onCancel: () => void; onConfirm: () => void }) { return ( <Row gap={8} justify="end" Size={new UDim2(1, 0, 0, 36)}> <textbutton Text="Cancel" Size={UDim2.fromOffset(88, 32)} Event={{ Activated: props.onCancel }} /> <textbutton Text="Confirm" Size={UDim2.fromOffset(88, 32)} Event={{ Activated: props.onConfirm }} /> </Row> );}Grid is responsive: minColumnWidth makes it measure its own AbsoluteSize, fit as many columns
as the container allows, and stretch cell width to fill. Pass a fixed columns count to opt out.
cellHeight sets row height, and gap covers both axes with per-axis overrides.
import React from "@rbxts/react";import { Grid } from "@lattice-ui/react-system";
export function InventoryGrid(props: { items: Array<Item> }) { return ( <Grid minColumnWidth={64} cellHeight={64} gap={6} autoSize={true} Size={new UDim2(1, 0, 0, 0)}> {props.items.map((item) => ( <ItemSlot key={item.id} item={item} /> ))} </Grid> );}Shrink the container and the grid drops columns. Widen it and columns come back, with no breakpoint code on your side.
Unlike the interaction primitives, these do not support asChild — passing it throws. They must own their host frame, since the layout and decoration instances live inside it. Use sx and direct host props instead.
The stack composes top-down. SystemProvider resolves theme and density once, surface tones give
panels one elevation language, and the layout primitives space children from the same resolved
scale. Build with tokens end-to-end and a single setDensity re-fits the entire UI.