Latticeguides

Density and layout

`SystemProvider`, density tokens, surface tones, and laying out HUDs with Stack, Row and Grid.

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

App root
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:

A density setting backed by 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:

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

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

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.

An elevation-consistent panel
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:

surface() applies a tone as sx props
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:

HUD column with Stack
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:

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

Inventory grid
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.

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.