references/styling-and-frameworks.md
# UI styling and declarative frameworks
## StyleSheet selectors
| Selector | Matches | Example |
| --- | --- | --- |
| `Class` | GuiObject/UIComponent class | `"TextButton"`, `"UICorner"` |
| `.Tag` | CollectionService tag | `".Primary"` |
| `#Name` | Instance name | `"#CloseButton"` |
| `:State` | `Enum.GuiState` (`Hover`, `Press`, `NonInteractable`, `Idle`) | `"TextButton:Hover"` |
| `@Query` | Active `StyleQuery`, or built-ins: `@PreferredInput{Gamepad,KeyboardAndMouse,Touch}`, `@ViewportDisplaySize{Small,Medium,Large}`, `@PreferredTextSize{Medium,Large,Larger,Largest}`, `@ReducedMotionEnabled{True,False}` | `"@ViewportDisplaySizeSmall"` |
| `::Class` | Pseudo-instance modifier (adds e.g. a UICorner without inserting one) | `"TextButton::UICorner"` |
| `A > B`, `A B` | Child / descendant combinators | `".Container > ImageLabel"` |
Property values can reference tokens with `$TokenName`. One `StyleSheet` applies per tree via a `StyleLink`.
## Tokens, theme, and rules in code
```luau
--!strict
local Players = game:GetService("Players")
local screenGui = Instance.new("ScreenGui")
screenGui.ResetOnSpawn = false
-- Design tokens live as attributes.
local tokens = Instance.new("StyleSheet")
tokens.Name = "Tokens"
tokens:SetAttribute("Primary", Color3.fromHex("3B82F6"))
tokens:SetAttribute("PrimaryHover", Color3.fromHex("60A5FA"))
tokens:SetAttribute("Radius", UDim.new(0, 8))
-- The sheet linked to the UI derives the tokens.
local sheet = Instance.new("StyleSheet")
sheet.Name = "AppStyle"
local derive = Instance.new("StyleDerive")
derive.StyleSheet = tokens
derive.Parent = sheet
local button = Instance.new("StyleRule")
button.Selector = ".Primary"
button:SetProperties({
BackgroundColor3 = "$Primary",
TextColor3 = Color3.new(1, 1, 1),
AutoButtonColor = false,
})
button.Parent = sheet
local hover = Instance.new("StyleRule")
hover.Selector = ".Primary:Hover"
hover:SetProperty("BackgroundColor3", "$PrimaryHover")
hover.Parent = sheet
local corner = Instance.new("StyleRule")
corner.Selector = ".Primary::UICorner"
corner:SetProperty("CornerRadius", "$Radius")
corner.Parent = sheet
local link = Instance.new("StyleLink")
link.StyleSheet = sheet
link.Parent = screenGui
local buy = Instance.new("TextButton")
buy.Text = "Buy"
buy.Size = UDim2.fromOffset(160, 48)
buy:AddTag("Primary")
buy.Parent = screenGui
tokens.Parent = screenGui
sheet.Parent = screenGui
screenGui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
```
Themes: create `LightTheme`/`DarkTheme` sheets that define the same token names, and point the
app sheet's `StyleDerive.StyleSheet` at the active theme to switch at runtime.
## React (jsdotlua/react)
```luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.Packages.ReactRoblox)
local function Counter(props: { label: string })
local count, setCount = React.useState(0)
return React.createElement("TextButton", {
Size = UDim2.fromOffset(200, 50),
Text = `{props.label}: {count}`,
[React.Event.Activated] = function()
setCount(count + 1)
end,
})
end
local container = Instance.new("ScreenGui")
container.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
local root = ReactRoblox.createRoot(container)
root:render(React.createElement(Counter, { label = "Clicks" }))
```
- Keep game state outside components (a store/controller); pass data down, emit intents up.
- Use `React.useEffect` cleanup to disconnect Roblox signals.
## Fusion (0.3 style)
```luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Fusion = require(ReplicatedStorage.Packages.Fusion)
local scope = Fusion.scoped(Fusion)
local count = scope:Value(0)
local button = scope:New("TextButton")({
Size = UDim2.fromOffset(200, 50),
Text = scope:Computed(function(use)
return `Clicks: {use(count)}`
end),
[Fusion.OnEvent("Activated")] = function()
count:set(Fusion.peek(count) + 1)
end,
})
-- scope:doCleanup() destroys everything created in this scope.
```
APIs differ between major versions of these libraries: check the installed version's docs.
SKILL.md
---
name: roblox-ui
description: Responsive cross-platform UI - ScreenGui and safe areas (ScreenInsets), Scale vs Offset, UIListLayout flex/UIFlexItem, constraints, StyleSheets/tokens/themes, RichText, gamepad/touch navigation, UIDragDetector, CanvasGroup, tweens, BillboardGui/SurfaceGui, React-lua/Fusion/Vide. Use when building HUDs, menus, shops, inventories, or fixing UI that breaks on mobile, console, or other screen sizes.
---
# Roblox UI
## Foundations
- Put UI in `StarterGui` as `ScreenGui`s (copied to each player's `PlayerGui`), or create it from a
client controller. UI code is client-only; the server never touches `PlayerGui` in normal designs.
- `ScreenGui` defaults to set: `ResetOnSpawn = false` (keep UI across deaths), `ZIndexBehavior = Sibling`,
`IgnoreGuiInset` per design, `ScreenInsets = CoreUISafeInsets` for interactive UI (clears notches and
the Roblox top bar); `DeviceSafeInsets`/`None` only for full-bleed backgrounds.
- **Size and position with Scale** (`UDim2.fromScale`) plus constraints; use Offset only for fixed
details (padding, strokes, icons). Pure-Offset UI breaks on phones and 4K screens.
- `AnchorPoint = Vector2.new(0.5, 0.5)` + `Position = UDim2.fromScale(0.5, 0.5)` centers an element.
- `UIAspectRatioConstraint` keeps shapes square/consistent; `UISizeConstraint` caps sizes;
`UITextSizeConstraint` with `TextScaled` bounds text size (prefer fixed `TextSize` + `AutomaticSize`
where possible — `TextScaled` produces inconsistent sizes).
- `AutomaticSize` lets frames grow with content (lists, tooltips, chat bubbles).
- Test every screen in the **Device Simulator** (phone portrait/landscape, tablet, console 10-foot, 4K).
## Layout
| Need | Use |
| --- | --- |
| Rows/columns that adapt | `UIListLayout` (`FillDirection`, `Padding`, `HorizontalFlex`/`VerticalFlex`, `Wraps`) |
| One item grows to fill space | `UIFlexItem` child with `FlexMode = Fill` (or `Grow`/`Shrink`/`Custom`) |
| Grids (inventory) | `UIGridLayout` inside a `ScrollingFrame` with `AutomaticCanvasSize` |
| Carousels/pages | `UIPageLayout` |
| Rounded corners, borders, padding | `UICorner`, `UIStroke`, `UIPadding` |
| Gradients, shadows | `UIGradient` (`Type` = `Linear`/`Radial`/`Conical`/`Elliptical`), `UIShadow` |
| Scale a whole group | `UIScale` |
```luau
--!strict
local Players = game:GetService("Players")
local playerGui = Players.LocalPlayer:WaitForChild("PlayerGui")
local screenGui = Instance.new("ScreenGui")
screenGui.Name = "Hud"
screenGui.ResetOnSpawn = false
screenGui.ScreenInsets = Enum.ScreenInsets.CoreUISafeInsets
local bar = Instance.new("Frame")
bar.Name = "Toolbar"
bar.AnchorPoint = Vector2.new(0.5, 1)
bar.Position = UDim2.new(0.5, 0, 1, -16)
bar.Size = UDim2.fromScale(0.6, 0.1)
bar.BackgroundTransparency = 0.3
bar.Parent = screenGui
local list = Instance.new("UIListLayout")
list.FillDirection = Enum.FillDirection.Horizontal
list.HorizontalFlex = Enum.UIFlexAlignment.Fill -- slots share the width equally
list.Padding = UDim.new(0, 8)
list.Parent = bar
Instance.new("UICorner").Parent = bar
local padding = Instance.new("UIPadding")
padding.PaddingLeft, padding.PaddingRight = UDim.new(0, 8), UDim.new(0, 8)
padding.Parent = bar
for slot = 1, 5 do
local button = Instance.new("TextButton")
button.Name = `Slot{slot}`
button.Text = tostring(slot)
button.Size = UDim2.fromScale(0, 1)
button.Parent = bar
Instance.new("UIAspectRatioConstraint").Parent = button
end
screenGui.Parent = playerGui -- parent last: one layout pass, no flicker
```
## Styling: StyleSheets (CSS-like)
Roblox's engine-level styling replaces copy-pasted property values:
- `StyleSheet` with `StyleRule`s (`Selector` like `"TextButton"`, `".Primary"` (tag), `"#Close"` (name),
`"TextButton:Hover"` (state), `"::UICorner"` (modifier pseudo-instance)) and `rule:SetProperties({...})`.
- **Tokens** are attributes on a token sheet (`$Primary`, `$Radius`); **themes** are sheets deriving tokens
(`StyleDerive`); swap themes by retargeting the `StyleDerive`.
- A `StyleLink` under a `ScreenGui` applies one sheet to that tree. **Style queries** (`@Name`) switch
styles by screen size, input type, or accessibility settings.
- Edit visually in Studio's **Style Editor**. Details and a code example: [references/styling-and-frameworks.md](references/styling-and-frameworks.md).
## Cross-platform input in UI
- Read `UserInputService.PreferredInput` (`KeyboardAndMouse`, `Touch`, `Gamepad`) and listen to
`UserInputService:GetPropertyChangedSignal("PreferredInput")` to swap button prompts and layouts.
Don't infer platform from `TouchEnabled`/`KeyboardEnabled` alone.
- Use `GuiButton.Activated` (works for mouse, touch, and gamepad) instead of `MouseButton1Click`.
- Gamepad/console: every interactive element `Selectable`, set `GuiService.SelectedObject` when a menu
opens, group with `SelectionGroup`/`NextSelection*` for predictable D-pad navigation, support `ButtonB`
to close. Minimum touch target ≈ 44×44 px; readable text ≥ 14 px on phones.
- Bind actions through the **Input Action System** (`InputBinding.UIButton` makes an on-screen button
trigger an action) — see `roblox-input`.
## Text
- `RichText = true` enables `<b>`, `<i>`, `<font color="#FF0">`, `<stroke>`, etc. Escape user text.
- **Any text from one player shown to another must be filtered** with `TextService:FilterStringAsync`
on the server (`roblox-text-chat`). That includes pet names, signs, guild names, trade notes.
- Use `FontFace = Font.new("rbxasset://fonts/families/GothamSSm.json", Enum.FontWeight.Bold)` for weights.
- Localize with `LocalizationTable`s and auto-translation; avoid baking text into images.
## Animation and feel
- Tween on the client: `TweenService:Create(frame, TweenInfo.new(0.2, Enum.EasingStyle.Quad), { Position = ... }):Play()`.
- Animate `UIScale.Scale` for pop effects rather than `Size` (keeps layout stable).
- `CanvasGroup` fades a whole subtree (`GroupTransparency`) but renders to a texture: use it for
fading panels, not for large always-visible UI (memory/quality cost).
- `ClipsDescendants` now clips **rotated** children and `Path2D` too (default since Sep 24, 2026;
`StarterGui.ClipsDescendantsSupportsRotation` opt-out is removed in November 2026). To keep a rotated child
unclipped, make it a sibling with a higher `ZIndex`. A rotated parent still clips only inside a `CanvasGroup`.
- Drag-and-drop: `UIDragDetector` (the deprecated `GuiObject.Draggable` no longer works for new work).
## World-space UI
- `BillboardGui` (faces camera: names, health bars) — set `MaxDistance`, `AlwaysOnTop` sparingly,
use `Size` in Scale for distance scaling; `SurfaceGui` for screens/signs on parts. Prefer
`Adornee` + keep them in `PlayerGui` for per-player content. They stop rendering when their part streams out.
- `ProximityPrompt` gives cross-platform interaction UI for free (validate on the server!).
## Frameworks
For more than a few screens, a declarative framework keeps state and UI in sync:
- **React** (`jsdotlua/react` + `react-roblox`) — components + hooks, familiar to web devs, scales to big apps.
- **Fusion** — reactive state objects and declarative instance creation.
- **Vide** — small, fast reactive library.
Match whatever the project already uses. Keep game state in controllers; UI only renders it and
emits user intents.
## Performance
- Avoid updating text/properties every frame when values haven't changed.
- Hide closed menus with `Visible = false` (or `Enabled = false` on the ScreenGui) instead of
destroying/recreating them repeatedly; build large lists lazily or virtualize them.
- Too many `UIGradient`/`CanvasGroup`/`UIStroke` on dynamic UI costs frame time; check the MicroProfiler.
## Related skills
`roblox-input` (actions, gamepad), `roblox-text-chat` (filtering), `roblox-monetization` (shops),
`roblox-luau`, `roblox-performance`.