From roblox-agent-skills
Building Roblox UI — engine primitives (GuiObjects, UDim2, AnchorPoint, layouts, inputs, containers) plus the Fusion 0.3 reactive framework. Use when writing, reviewing, or debugging UI components, sizing/positioning, layout structures, input handling, render order, or component lifecycle. UI layer only — engine semantics live in roblox-dev; language rules live in luau-expert; Rojo/Wally workflow lives in roblox-toolchain.
How this skill is triggered — by the user, by Claude, or both
Slash command
/roblox-agent-skills:roblox-uiThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
How Roblox UI works at the engine level + how to build it with **Fusion 0.3**.
How Roblox UI works at the engine level + how to build it with Fusion 0.3.
Sources: create.roblox.com/docs/ui (engine primitives), elttob.uk/Fusion/0.3 (Fusion docs), github.com/dphfox/Fusion (source).
For deep dives see:
references/roblox-ui-primitives.md — full GuiObject / layout / input referencereferences/design-guidelines.md — framework-agnostic UX, spacing/type/color scales, states, motion, accessibilityreferences/fusion-instances.md — New / Hydrate / [Children] / [OnEvent] / [OnChange] / [Out] / [Attribute] / [Child] referencereferences/fusion-state.md — Value, Computed, Observer, peek, use, animation primitivesThis skill describes preferred patterns; it does not authorize rewriting existing UI. Apply rules to new components and to changes the user explicitly asks for. Match the project's chosen UI framework. If the codebase uses Roact, don't migrate to Fusion unprompted. If existing components mix offset and scale, follow that mix. If there's already a theme module, use it — don't create a parallel one. Design Guidelines are recommendations, not enforcement.
Full rules: ../../shared/integration-policy.md.
Every Roblox UI is a tree of GuiObjects parented under a UI container. The container determines where it renders; the GuiObjects determine what shows up.
| Container | Where it shows | Lives under |
|---|---|---|
ScreenGui | Flat 2D overlay on the player's screen | Players[X].PlayerGui (replicated from StarterGui per-character) |
SurfaceGui | On a face of a BasePart in the 3D world (like a billboard) | A BasePart, or StarterGui with Adornee set |
BillboardGui | Floating in 3D, always faces camera, optional MaxDistance | A BasePart / Attachment, or StarterGui with Adornee |
ScreenGui properties worth knowing:
DisplayOrder — int ordering between sibling ScreenGuis (higher = on top).IgnoreGuiInset — when false (default), the topbar's reserved area shifts the visible region. Set true for full-screen overlays (loading screens, modal dialogs).ResetOnSpawn — if true, the GUI is destroyed and recloned from StarterGui every respawn. Set false for persistent state (HUDs, currency displays).ZIndexBehavior — Sibling (default; ZIndex only orders within same parent) vs Global (ZIndex orders across the whole tree). Almost always leave Sibling.SurfaceGui extras:
Face — which face of the part it shows on.SizingMode — FixedSize (use CanvasSize directly) vs PixelsPerStud.AlwaysOnTop — render even through occluders.BillboardGui extras:
Size here is in studs (UDim) not pixels — distance-scaled.MaxDistance culls the GUI past that range.StudsOffset / StudsOffsetWorldSpace — position in 3D relative to the adornee.GuiObject core propertiesEvery GuiObject (Frame, TextLabel, ImageButton, etc.) shares:
Size: UDim2 — {ScaleX, OffsetX}, {ScaleY, OffsetY}. Scale is fraction of parent; Offset is pixels. Both add.Position: UDim2 — same shape; offset of the AnchorPoint inside the parent.AnchorPoint: Vector2 — (0..1, 0..1). Origin of position + scaling. Default (0,0) = top-left. (0.5, 0.5) = centered (use with Position = UDim2.fromScale(0.5, 0.5) to center in parent).AbsoluteSize: Vector2 — read-only, computed in pixels at the current frame.AbsolutePosition: Vector2 — read-only, computed in pixels at the current frame.AutomaticSize: Enum.AutomaticSize — None / X / Y / XY. Sizes the object to fit its content. Pairs with Size set to (0,0,0,0) along the auto axis.ClipsDescendants: boolean — clip children to this object's bounds.Visible: boolean — show/hide. Hidden objects don't receive input.Active: boolean — must be true for MouseButton1Down-style events on plain Frames; buttons (TextButton/ImageButton) are always active.ZIndex: number — render order within siblings (higher = front).LayoutOrder: number — sort order inside a layout structure (UIListLayout, etc.).Selectable: boolean — gamepad selection eligibility.UDim2 cheatsheetUDim2.new(sx, ox, sy, oy) -- explicit
UDim2.fromOffset(px, py) -- pure pixel: (0, px, 0, py)
UDim2.fromScale(sx, sy) -- pure scale: (sx, 0, sy, 0)
UDim2.new(0.5, 0, 0.5, 0) -- center if AnchorPoint is (0.5, 0.5)
UDim2.new(1, -20, 1, -20) -- fill parent minus 10px margin on each side
Rule: mix scale + offset deliberately. Pure scale = layout fluid with screen size. Pure offset = pixel-perfect, breaks on phones. Most production UI uses scale for outer containers and offset for inner content.
Drop one of these as a child to take over the layout of its siblings. The siblings' Position is ignored; Size may be too depending on the layout.
| Class | What it does |
|---|---|
UIListLayout | Stacks children along an axis (FillDirection = Vertical/Horizontal). Honors LayoutOrder, Padding, HorizontalAlignment / VerticalAlignment, SortOrder. |
UIGridLayout | Grid with CellSize + CellPadding. |
UIPageLayout | One child visible at a time; :JumpTo(child) / :Next() / :Previous(). Common for tabs / wizards. |
UITableLayout | Rows of equal height; columns of equal width per row. Less common than UIGridLayout. |
-- UIListLayout — vertical stack with 8px gap, sorted by LayoutOrder
local container = Instance.new("Frame")
local layout = Instance.new("UIListLayout", container)
layout.FillDirection = Enum.FillDirection.Vertical
layout.Padding = UDim.new(0, 8)
layout.SortOrder = Enum.SortOrder.LayoutOrder
Drop one as a child of the GuiObject it modifies:
| Class | Effect |
|---|---|
UICorner | Rounded corners. CornerRadius: UDim. |
UIPadding | Inset for children (top/bottom/left/right UDim). Doesn't change own size; affects child layout. |
UIStroke | Outline. Thickness, Color, Transparency, ApplyStrokeMode = Border / Contextual, StrokeSizingMode. Replaces the old border properties. Set StrokeSizingMode = Enum.StrokeSizingMode.ScaledSize so thickness scales with parent size — keeps strokes proportional across screen sizes. Default FixedSize keeps a constant pixel thickness regardless of UI scale. |
UIScale | Multiplier on the GuiObject's effective size + descendants. Useful for global zoom. |
UIAspectRatioConstraint | Lock width/height ratio. AspectRatio, AspectType = ScaleWithParentSize / FitWithinMaxSize, DominantAxis. |
UISizeConstraint | Min/max pixel size. |
UITextSizeConstraint | Min/max text size for TextScaled text. |
UIGradient | Linear gradient; tints by Color: ColorSequence + Transparency: NumberSequence + Rotation. Affects descendants. |
UIFlexItem | Per-child flex weights inside a flex-enabled UIListLayout. |
Frame — plain rectangle. BackgroundColor3, BackgroundTransparency, BorderSizePixel = 0 (modern UI sets this to 0 always).ScrollingFrame — clipped frame with scroll. Set CanvasSize: UDim2 (or AutomaticCanvasSize = X/Y/XY to fit content automatically). ScrollBarImageColor3 + ScrollBarThickness style the bar.CanvasGroup — group children for compositing (alpha-blends as one layer; enables full-group transparency without per-child fade).TextLabel / TextButton / TextBox — text. Properties: Text, Font (legacy) or FontFace: Font (modern), TextSize, TextColor3, TextWrapped, TextScaled (autosizes to fit; pair with UITextSizeConstraint), RichText (parses <b>, <i>, <font color="...">, etc.), TextXAlignment / TextYAlignment, LineHeight.TextBox extras: PlaceholderText, PlaceholderColor3, ClearTextOnFocus, MultiLine, :CaptureFocus() / :ReleaseFocus(). Events: Focused, FocusLost(enterPressed, inputThatCausedFocusLoss).ImageLabel / ImageButton — Image: Content, ImageColor3, ImageTransparency, ScaleType (Stretch / Tile / Slice / Fit / Crop), SliceCenter: Rect (9-slice), TileSize: UDim2 (for tile mode).ViewportFrame — render a 3D scene inside a UI element. Has its own Camera, WorldModel. Use for item previews, character cards.VideoFrame — play rbxasset:// videos. Limited use case; prefer in-world video on parts.For buttons (TextButton / ImageButton):
button.Activated:Connect(function(inputObject, numClicks)
-- Fires for: left mouse click, touch tap, gamepad A, Enter when focused.
end)
Activated is the right default — it abstracts mouse/touch/gamepad. Use only when you need lower-level events:
MouseButton1Down / MouseButton1Up / MouseButton2Down / etc. — mouse-specific.TouchTap / TouchPan / TouchPinch — touch-specific.For arbitrary GuiObjects (set Active = true first):
InputBegan(input) / InputChanged(input) / InputEnded(input) — receives InputObject with UserInputType (Mouse / Touch / Gamepad / Keyboard) and KeyCode / Position / Delta.For global input (camera, hotkeys, etc.) don't put listeners on a GuiObject — use:
UserInputService — global signals: InputBegan, InputChanged, InputEnded, JumpRequest, plus gamepad helpers. Always provides a gameProcessed: boolean second arg — if true, the engine already consumed the input (e.g. user typed in a TextBox, clicked a button). Skip when gameProcessed is true unless you have a reason.ContextActionService — bind named actions to multiple inputs at once with priorities and runtime rebinding. Better for rebindable hotkeys.ScreenGuis: DisplayOrder (higher = front).ScreenGui (siblings): ZIndex (higher = front). Default behavior is Sibling — only orders within same parent.LayoutOrder (lower = first in the layout direction).ScreenGui.ZIndexBehavior = Global makes ZIndex order across the whole tree — almost never desired; usually a sign of a workaround for incorrect parenting.
Roblox reserves an area at the top of the screen for the chat / leaderboard / menu button. By default, every ScreenGui is offset to start below this area.
ScreenGui.IgnoreGuiInset = true to fill the entire screen (loading screens, modal full-screen UIs, splash screens).GuiService:GetGuiInset() returns (topLeft: Vector2, bottomRight: Vector2) describing the reserved insets — useful when manually positioning.AbsoluteSize / AbsolutePosition are 1-frame stale on creation. A freshly-parented Frame reports (0, 0) until the first layout pass. Wait RunService.Heartbeat once before measuring, or use the :GetPropertyChangedSignal("AbsoluteSize") to react.AutomaticSize cycles. If a parent has AutomaticSize = X and a child is Size = UDim2.fromScale(1, 0), you've created a cycle (parent measures children, child wants 100% of parent). Behavior is implementation-defined; avoid.AnchorPoint = Vector2.new(0.5, 0.5) + Position = UDim2.fromScale(0.5, 0.5). Any other approach is fighting the system.UIAspectRatioConstraint on a Frame whose Size is pure scale, e.g. UDim2.fromScale(0.8, 1) + AspectRatio = 16/9. Container fills 80% of width but height becomes 80%-width / aspect.Size = UDim2.fromScale(1, 1) + UIAspectRatioConstraint with AspectRatio = 1, AspectType = FitWithinMaxSize, DominantAxis = Width. The frame fills its parent on the smaller axis and stays square — works for any parent shape (wide, tall, dynamically resized).Offset + UISizeConstraint. Pure scale UIs render at unpredictable sizes on small screens.TextScaled + UITextSizeConstraint clamps the auto-fit text size between min/max — without the constraint, very small parent heights produce 1px text.UIStroke with low transparency offset visually, or stack ImageLabels with ScaleType = Slice and a 9-slice shadow PNG.ImageButton + UICorner + ClipsDescendants, or use ScaleType = Slice on a 9-sliced rounded asset — UICorner is the modern path.Frame with BackgroundTransparency = 0.5 and a high ZIndex. Set Active = true so it intercepts clicks. Set Modal = true on the contained TextBox / button for gamepad / keyboard navigation lock.UserInputService is more reliable than InputBegan/Changed/Ended chains because you don't lose tracking when the cursor leaves the source GuiObject. Track delta via input.Delta (mouse) or input.Position (touch).GuiService.SelectedObject and NextSelectionUp/Down/Left/Right on each GuiObject configure the focus graph. Set Selectable = true on every focusable item.UserInputService.MouseIconEnabled = false. Restore on cleanup.TextService:GetTextSize(text, size, font, frameSize) -> Vector2. Useful before sizing a parent. Prefer AutomaticSize if you can.FontFace is a Font datatype (Font.new(family, weight, style)). The legacy Font: Enum.Font still works but the family enum is frozen. Prefer FontFace for new code.Full primitive reference: references/roblox-ui-primitives.md.
Framework-agnostic. Applies to Fusion, Vide, React Lua, or raw Instance.new. Aesthetic target: neutral clean product (think Discord / Notion / modern game launchers — works in most contexts without committing to one art style). Optimize for UX and consistency, not novelty.
RunService.Heartbeat re-renders, large rgb gradients on every frame, deep instance trees that re-replicate.Pick one base unit and multiply. The agent default:
4 8 12 16 24 32 48 64
In scale terms (relative to a parent's smaller axis, ~720–1080 dp): 0.005, 0.01, 0.015, 0.02, 0.03, 0.04, 0.06, 0.08. Use UIPadding + UIListLayout.Padding for spacing — never magic offsets between elements.
Rule: every UIPadding value, every layout gap, every margin should be a step on this scale. If you find yourself typing UDim.new(0.017, 0) you've left the scale.
12 14 16 18 24 32 48
Roles:
| Size | Use |
|---|---|
| 12 | Captions, metadata, footnotes |
| 14 | Body small, secondary labels |
| 16 | Body, button text (default) |
| 18 | Emphasized body, card titles |
| 24 | Section headings |
| 32 | Page titles |
| 48 | Display / hero only |
One font family for the whole UI. Two max (body + display). Use FontFace = Font.fromName(...) and reuse the same Font across the project. Always pair TextScaled = true with UITextSizeConstraint { MaxTextSize = N } from this scale.
Semantic roles, not raw colors:
Surface — base background.SurfaceElevated — cards, modals (one shade lighter than Surface on dark UIs).Primary — main brand action.OnPrimary — text/icon on Primary.Text — primary text.TextMuted — secondary text.Border — dividers, strokes.Success / Warning / Danger — semantic feedback.Centralize in one module/table. Never inline Color3.fromRGB(0, 120, 255) at call sites — refer to theme.Primary.
Contrast: WCAG AA = 4.5:1 for body text, 3:1 for large text (18+). Test palettes; many "pretty" combinations fail.
Don't:
Color3.new(0, 0, 0) text on pure white — too harsh; use (0.1, 0.1, 0.12) ish.UIListLayout + spacing scale. Whitespace is the cheapest way to communicate "these belong together" / "these don't".UIListLayout / UIGridLayout rather than per-child positions. Misaligned text/edges read as sloppy.Every button / input / selectable needs:
| State | Visual cue |
|---|---|
| Default | Base style |
| Hover (mouse) | Slight tint shift or border highlight |
| Pressed | Darker / inset look |
| Focused (gamepad / keyboard) | UIStroke highlight, never just color |
| Disabled | Reduced contrast, no hover; Active = false |
| Loading | Spinner / skeleton; lock further input |
| Error | Inline message + icon |
Don't ship a button without at least Default + Hover + Pressed + Disabled.
Quad / Sine Out for entry. Quad / Sine In for exit. Linear only for indeterminate spinners.UISizeConstraint { MinSize = Vector2.new(44, 44) } even when the button is scale-sized.MouseCursor on ImageLabel/Button) for clickables.NextSelectionUp/Down/Left/Right). GuiService.SelectedObject set on screen open.UIAspectRatioConstraint on key panels so they don't squash.UISizeConstraint for min/max pixel bounds — UI shouldn't shrink below readable.UserInputService.TouchEnabled, GamepadEnabled) for behavior, not for layout.For every surface that loads async data:
UIStroke on focused element.| ❌ | ✅ |
|---|---|
Magic-number spacing (UDim.new(0.017, 0)) | Step from the spacing scale |
| Inline colors at call sites | Theme module / table |
| Same-size siblings forming a wall of text | Hierarchy via size/weight/color |
| Two primary buttons side-by-side | One primary + one secondary |
| Drop-shadow under every card | Reserve elevation for things that pop forward |
| Border radii: 4px / 8px / 12px / 6px on different elements | Pick 2 radii max (e.g. UDim.new(0.1, 0) for buttons, UDim.new(0.04, 0) for cards) |
BorderSizePixel = 1 | Always 0; use UIStroke if you need an edge |
| Silent disabled state (button just doesn't do anything) | Visibly muted + Active = false + AutoButtonColor = false |
| Snap state changes (instant color flip) | Tween / Spring the transition |
| Pure scale on a phone-sized device | Combine scale with UISizeConstraint minimums |
| Color-only error (red border, no message) | Icon + label + color |
| Empty state showing nothing | Guidance + CTA |
| Three different fonts | One body + one display max |
| Comic-sans-tier font choice for prod UI | Modern sans (BuilderSans / Inter / SF / Roboto family) |
| Per-element animation timing | Centralized motion tokens (e.g. MOTION_FAST = 0.15) |
Per-frame RunService.Heartbeat UI rebuilds | State-driven; let the framework batch |
| Hover-only affordance for clickables | Visible enough that you don't need to hover to find it |
| Ignoring topbar inset | Set IgnoreGuiInset deliberately based on intent |
Full coverage with code examples in references/design-guidelines.md.
Fusion, Vide, and React Lua have incompatible reactive models. Pick one for a project. Wrapping one in another breaks lifecycle assumptions and creates leaks. If you must interop (e.g. Roact-Fusion bridge), read the bridge's docs first; don't roll your own.
| Library | Mental model |
|---|---|
| Fusion | Reactive declarative; scope-based lifecycle; state objects + computeds |
| Vide | Lightweight reactive; sources + effects; functional-only API |
| React Lua | React-style components; hooks; element tree |
Reactive declarative UI with explicit scope-based lifecycle.
Fusion.doCleanup(scope) runs them in reverse order. No scope = leak.scope:Value(initial) is the unit of mutable state. Read with peek(stateObj). Write with stateObj:set(new).scope:Computed(function(use, scope) ... end) re-runs when any use(stateObj) changes. The calculation must be immediate (no yields).scope:New "TextLabel" { ... } declares the instance with its props and children inline; Fusion handles creation, defaults, hydration, cleanup.A scope is an array of cleanup tasks. Things added to a scope are destroyed when the scope is cleaned up.
local Fusion = require(ReplicatedStorage.Packages.Fusion)
local scoped = Fusion.scoped
local doCleanup = Fusion.doCleanup
local scope = scoped(Fusion) -- scope with all Fusion methods bound
local health = scope:Value(100)
local name = scope:Value("Player")
-- ...later
scope:doCleanup() -- destroys everything in reverse order
scoped(Fusion) returns a scope with every Fusion function attached as a method, callable with the scope itself as the first arg via :Method(...) syntax. Equivalent forms:
local v = Fusion.Value(scope, 5) -- function form
local v = scope:Value(5) -- method form (preferred)
scope:innerScope() — child scope auto-cleaned when parent is cleaned. Use for short-lived UI (dropdowns, modals).scope:deriveScope() — sibling scope with the same methods but no auto-clean link. Use sparingly; prefer innerScope.local uiScope = scoped(Fusion)
uiScope:insert(
dropdownOpened:Connect(function()
local dropdownScope = uiScope:innerScope() -- auto-cleaned with uiScope
-- build dropdown using dropdownScope...
dropdownScope:insert(
dropdownClosed:Connect(function() dropdownScope:doCleanup() end)
)
end)
)
scope:insert(task1, task2, ...) adds tasks (Connections, Instances, callbacks) to a scope and returns them.
doCleanup acceptsInstances (calls :Destroy()).RBXScriptConnections (calls :Disconnect()).:destroy() / :Destroy() / :disconnect() / :Disconnect().Valuelocal health = scope:Value(100)
print(Fusion.peek(health)) -- 100
health:set(75)
print(Fusion.peek(health)) -- 75
peek(stateObj) reads current value. Doesn't subscribe to changes.
ComputedDerived state. The callback receives (use, scope). use(stateObj) reads and subscribes — the computed re-runs when any use'd state changes.
local coins = scope:Value(50)
local price = scope:Value(10)
local remaining = scope:Computed(function(use, scope)
return use(coins) - use(price)
end)
print(peek(remaining)) -- 40
coins:set(25)
print(peek(remaining)) -- 15
Rules:
task.wait, RemoteEvents, etc.).use(x) reads + subscribes; peek(x) only reads.scope parameter is a fresh inner scope that's auto-cleaned each time the computed recalculates. Use it for objects whose lifetime should match the calculation:scope:Computed(function(use, scope)
local current = use(number)
table.insert(scope, function() print("Cleaning up", current) end)
return current * 2
end)
-- The cleanup runs each time `number` changes, before recalculating.
Observer for those.ObserverFires a callback when a state object changes.
local health = scope:Value(100)
local healthChanged = scope:Observer(health)
healthChanged:onChange(function()
print("health is now", peek(health))
end)
For side effects (sound, network, logging) tied to state changes — never put side effects in Computed.
peek and usepeek(x) — global read. Doesn't subscribe.use(x) — only inside Computed callback. Reads + subscribes for re-runs.Always name the first computed parameter use even when shadowing — --!nolint LocalShadow suppresses Luau warnings if needed.
NewCreate an instance with props inline:
local message = scope:Value("Hello!")
local label = scope:New "TextLabel" {
Name = "Greeting",
Parent = playerGui.ScreenGui,
Text = message, -- state object → live binding
BackgroundTransparency = 1,
Size = UDim2.fromOffset(200, 50),
}
State objects bound to props update the instance automatically on the next frame after :set(). Constants are applied once.
scope:New "X" { ... } is sugar for scope:New("X")({ ... }). The two-call form is required when not using string literals + curly braces.
Fusion's New applies its own sane defaults (e.g. UI borders off, background transparency 1, automatic colors off) — different from Instance.new() defaults. See Fusion source's defaultProps.luau for the full list.
scope:Hydrate(existingInstance) { ... } applies the same prop logic to an instance you already own.
scope:New "TextButton" {
Text = "Click me",
[Children] = {
scope:New "UICorner" { CornerRadius = UDim.new(0, 8) },
scope:New "UIPadding" { PaddingLeft = UDim.new(0, 12), PaddingRight = UDim.new(0, 12) },
},
[OnEvent "Activated"] = function(_, numClicks)
print("clicked", numClicks)
end,
[OnChange "AbsoluteSize"] = function(newSize)
print("resized to", newSize)
end,
[Out "Text"] = textState, -- write Text property out to a state object
}
Children, OnEvent, OnChange, Out are imported directly from Fusion, not via scope.
local Children = Fusion.Children
local OnEvent = Fusion.OnEvent
local OnChange = Fusion.OnChange
local Out = Fusion.Out
Capturing the Instance: Fusion 0.3 has no Ref key. scope:New "X" {...} returns the Instance — assign it to a local and use it directly:
local frame = scope:New "Frame" { ... }
-- ...elsewhere:
frame.BackgroundColor3 = Color3.new() -- direct mutation; for reactive props, use a Value bound to the prop
Older Fusion (0.2 and earlier) had Ref; 0.3 removed it.
[Children] accepts: a single Instance, an array of Instances (any nesting depth), or a state object containing either.
Full reference: references/fusion-instances.md.
A component is a function that takes (scope, props) and returns an Instance.
type UsedAs<T> = Fusion.UsedAs<T> -- "T or a state object holding T"
local function Button(
scope: Fusion.Scope,
props: {
Text: UsedAs<string>,
OnClick: () -> ()?,
Size: UsedAs<UDim2>?,
LayoutOrder: UsedAs<number>?,
}
): TextButton
return scope:New "TextButton" {
Size = props.Size or UDim2.fromOffset(120, 40),
LayoutOrder = props.LayoutOrder,
BackgroundColor3 = Color3.fromRGB(0, 120, 255),
Text = props.Text,
TextColor3 = Color3.new(1, 1, 1),
TextSize = 18,
[Children] = scope:New "UICorner" { CornerRadius = UDim.new(0, 6) },
[OnEvent "Activated"] = function() if props.OnClick then props.OnClick() end end,
}
end
return Button
Use it via either form:
-- Function form
local btn = Button(scope, { Text = "OK", OnClick = save })
-- Method form (after registering on the scope)
local scope = scoped(Fusion, { Button = Button })
local btn = scope:Button { Text = "OK", OnClick = save }
UsedAs<T> is the right prop type for "constant or state object" — peek() and use() handle both transparently.
UsedAs<T>? and UsedAs<T?> look similar but mean different things:
-- Optional with a sensible default — caller may omit; component substitutes a default.
Position: UsedAs<UDim2>?
-- Nullable — `nil` is itself a meaningful value the component reacts to
-- (e.g. cleared input, no selection). Caller must pass it; the inner T is what's optional.
Value: UsedAs<string?>
Rule: pick UsedAs<T>? when the prop has a default. Pick UsedAs<T?> when the absence of a value is part of the component's state model.
The scope parameter can carry the methods a component expects. Two patterns:
-- Public component: constrain the scope so callers see what's required.
-- typeof(Fusion) covers all built-in constructors; narrow further if you want.
local function Card(
scope: Fusion.Scope<typeof(Fusion)>,
props: { ... }
): Frame
return scope:New "Frame" { ... }
end
-- Implementation-detail helpers: take a plain Fusion.Scope and derive an
-- inner scope with the helpers attached. Callers don't need to know about them.
local function Modal(scope: Fusion.Scope, props: { ... }): Frame
local inner = scope:innerScope { Backdrop = Backdrop, FocusTrap = FocusTrap }
return inner:New "Frame" { ... }
end
Rule: prefer innerScope for niche helpers. Keeps the public signature clean and lets callers pass any compatible scope.
Each component (Button, Card, Modal) lives in its own ModuleScript. Improves discoverability, keeps files focused, encourages reuse. Compose by registering on a parent scope:
local scope = scoped(Fusion, { Button = Button, Card = Card })
local ui = scope:Card { [Children] = scope:Button { Text = "OK" } }
UI Labs is a Storybook-style Studio plugin for Roblox. Preview components without running the game; hot-reloads on file change; sandboxed environment so test mounts don't pollute project state.
Use stories when iterating on a single component visually, exposing controls (text, color, boolean) for design review, or covering states (default / hover / disabled / loading) without wiring a full game scene.
14293316215 (or build from GitHub source).pepeeltoro41/ui-labs@rbxts/ui-labsUI Labs runs without the package — the package adds typed helpers like CreateFusionStory.
ModuleScript whose name ends in .story (e.g. Button.story.luau). UI Labs auto-discovers them.ModuleScript whose name ends in .storybook. Returns a table with:
storyRoots (required) — array of Instances to scan for stories.name (optional) — display name; defaults to the module name.groupRoots (optional, boolean) — separate folder per storyRoots entry.return {
fusion = Fusion,
controls = { Visible = true, Label = "Click me" },
story = function(props)
-- props.target : Frame to mount into
-- props.controls : Fusion.Value for each control, reactive
-- props.scope : Fusion 0.3 scope, auto-cleanup on unmount
return props.scope:New "TextButton" {
Parent = props.target,
Size = UDim2.fromScale(1, 1),
Visible = props.controls.Visible,
Text = props.controls.Label,
}
-- No cleanup return needed — props.scope is doCleanup'd on unmount.
end,
}
Without props.scope (older Fusion or manual cleanup) the story function must return one of:
Instance — destroyed on unmount, OR() -> () cleanup function — invoked on unmount.Every key in the controls table becomes a Fusion.Value exposed at props.controls.<name>. Bind directly to instance props for live updates as the designer scrubs the control panel — no manual Observer needed.
Primitive controls (string, number, boolean, Color3, EnumItem, etc.) infer from the default value. Advanced controls (slider ranges, choice dropdowns) ship via the utility package — see /docs/controls.
props.target — UI Labs can't sandbox or unmount it; persists across reloads.scoped(Fusion) inside the story — use props.scope so unmount tears down cleanly.Players.LocalPlayer, RemoteEvents) — stories should run with mocked inputs; pass values via controls.nil without using props.scope — instance leaks on unmount.See examples/fusion-story.luau.
| ❌ | ✅ |
|---|---|
| Creating Fusion objects without a scope | Always scoped(Fusion) first |
Using peek() inside a Computed callback | Use use(x) so re-runs trigger |
Yielding inside a Computed callback | Computeds must be immediate; do async work elsewhere and feed a Value |
Side effects in Computed (sounds, network) | Put them in Observer:onChange(...) |
Forgetting scope:doCleanup() on UI tear-down | Always tie scope lifetime to a parent (root scope, innerScope of a parent) |
Instance.new("Frame") then setting properties | scope:New "Frame" { ... } — gets defaults + cleanup for free |
Children added with child.Parent = parent after-the-fact | Use [Children] in the prop table |
Connecting events with inst.Activated:Connect(...) directly on a Fusion-created instance | [OnEvent "Activated"] = handler — gets cleaned with the scope |
Storing a constant where the API wants UsedAs<T> | Pass the constant directly; Fusion handles it |
scope:Computed capturing scope in cleanup callbacks | Use the second scope param — it's auto-cleaned per recalculation |
| Passing one scope to multiple unrelated UI trees | innerScope/deriveScope per logical group |
Using :Destroy() on a Fusion-managed instance | doCleanup the scope; let Fusion tear down |
| Mixing Fusion with Vide / React Lua in the same tree | Pick one |
wally.toml), verify the API against that version's docs before quoting.elttob.uk/Fusion/0.3 is the canonical reference for 0.3 — older Fusion docs (0.2 and earlier) used a different API (no scopes; Computed had a different signature). Don't mix tutorials._ aren't stable.UsedAs<T> is the modern name for what older docs called CanBeState<T> or StateOrValue<T>. Same idea; verify the actual exported type name in your installed Fusion.examples/fusion-counter.luau — Value + Computed + Observer + New; full reactive chain.examples/fusion-button.luau — Button component with UsedAs props, scoped registration.examples/fusion-list.luau — dynamic children driven by a state object.examples/fusion-form.luau — [Out "Text"] capture + Instance-by-assignment for programmatic focus; submit handler.examples/fusion-animation.luau — Tween + Spring: hover scale (Spring), color/transparency transitions (Tween), drop-shadow fade.examples/fusion-story.luau — UI Labs .story module for a Fusion component; controls + props.scope cleanup.npx claudepluginhub afrxo/roblox-agent-skills --plugin roblox-agent-skillsProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.