Use when creating or changing a UI component in a Roblox project, or when asked to preview UI without playtesting. Covers writing .story and .storybook modules for the UI Labs plugin (pepeeltoro41/ui-labs) and Hoarcekat, for Fusion, Vide, React Lua and plain Instances, with controls and cleanup.
Use the robloxvibecoders MCP: call get_resource with id "skill-roblox-ui-stories" and install it as a Claude Code skill in this project (.claude/skills/roblox-ui-stories/SKILL.md).
Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.
What your AI reads
SKILL.md
---
name: roblox-ui-stories
description: Use when creating or changing a UI component in a Roblox project, or when asked to preview UI without playtesting. Covers writing .story and .storybook modules for the UI Labs plugin (pepeeltoro41/ui-labs) and Hoarcekat, for Fusion, Vide, React Lua and plain Instances, with controls and cleanup.
---
# UI stories with UI Labs and Hoarcekat
A **story** is a ModuleScript whose name ends in `.story`. A storybook plugin finds it and renders the component into a
preview frame inside Studio, in edit mode, with hot reload. You see a button, a shop window or a HUD in every state
without pressing Play, walking to a shop, or faking data on a server.
Two plugins read stories:
- **UI Labs** (PepeElToro41): the current choice. Hot reload, sandboxed environment, controls to tweak props live,
storybooks, and built-in support for Fusion, Vide, React, Roact and Iris. Get it from the
[Creator Store](https://create.roblox.com/store/asset/14293316215/UI-Labs).
- **Hoarcekat** (Kampfkarren/hoarcekat): the older, minimal plugin. It only understands function stories.
Function stories work in both, so they are the safe default. Use UI Labs table stories when you want controls.
## Rules for an agent
1. Every new reusable component gets a story next to it: `Button.luau` and `Button.story.luau`.
2. Components take data and callbacks as props. A story has no `Players.LocalPlayer`, no server and no remotes, so a
component that reaches for them can't be previewed. Pass mock data from the story.
3. Cover the states that break UI: empty list, very long text, zero and huge numbers, disabled, loading, and a
phone-sized frame.
4. Every story cleans up what it creates.
## Naming with Rojo
Rojo drops only the `.luau` extension, so `ShopButton.story.luau` becomes a ModuleScript named `ShopButton.story`,
and `Game.storybook.luau` becomes `Game.storybook`. Stories must be synced into the DataModel (for example under
ReplicatedStorage) for the plugin to find them.
## Function stories (UI Labs and Hoarcekat)
The module returns a function. It receives the target `Frame`, renders into it, and returns a cleanup function.
```luau
-- ShopButton.story.luau, Fusion 0.3
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Fusion = require(ReplicatedStorage.Packages.Fusion)
local ShopButton = require(script.Parent.ShopButton)
return function(target: Frame)
local scope = Fusion.scoped(Fusion)
local button = ShopButton(scope, {
Text = "Buy - 250",
OnClick = function() print("clicked") end,
})
button.Parent = target
return function()
scope:doCleanup()
end
end
```
The same shape for Vide and React (requires left out):
```luau
-- Vide
return function(target: Frame)
return Vide.mount(function()
return ShopButton { Text = "Buy - 250", OnClick = function() end }
end, target) -- mount returns the destroy function, which is the cleanup
end
```
```luau
-- React Lua
return function(target: Frame)
local root = ReactRoblox.createRoot(target)
root:render(React.createElement(ShopButton, { text = "Buy - 250" }))
return function()
root:unmount()
end
end
```
If the story function errors after it mounted something, the cleanup is never returned and the instances stay
behind. Keep the mounting code simple and fix errors before re-running.
## UI Labs table stories with controls
Install the utility package for control helpers and story creators:
```toml
[dependencies]
UILabs = "pepeeltoro41/ui-labs@2.4.2"
```
A table story names its library and has a `story` function. `controls` become editable fields in the plugin.
```luau
-- Fusion 0.3: called once. Controls arrive as Fusion Values; props.scope is cleaned up for you.
local UILabs = require(ReplicatedStorage.Packages.UILabs)
-- Fusion and ShopButton are required as in the function story above
local controls = {
Text = "Buy",
Price = UILabs.Slider(250, 0, 10000, 1),
Disabled = false,
}
return {
summary = "Purchase button in the shop grid",
fusion = Fusion,
controls = controls,
story = function(props)
local scope = props.scope
local button = ShopButton(scope, {
Text = scope:Computed(function(use)
return `{use(props.controls.Text)} - {use(props.controls.Price)}`
end),
Disabled = props.controls.Disabled,
OnClick = function() end,
})
button.Parent = props.target
end,
}
```
```luau
-- Vide: called once inside a stable scope. Controls arrive as sources. Return the instance.
return {
vide = Vide,
controls = { Text = "Buy" },
story = function(props)
return ShopButton { Text = props.controls.Text, OnClick = function() end }
end,
}
```
```luau
-- React: story is called again whenever a control changes, with plain values. Return an element.
return {
react = React,
reactRoblox = ReactRoblox,
controls = { Text = "Buy" },
story = function(props)
return React.createElement(ShopButton, { text = props.controls.Text })
end,
}
```
- Primitive controls are literal values: strings, numbers, booleans and `Color3`.
- Helpers from the utility package: `UILabs.Slider(default, min, max, step?)`, `UILabs.Choose(options, index?)`,
`UILabs.EnumList({ Name = value }, defaultKey)`, `UILabs.RGBA(color, transparency?)` (gives
`{ Color, Transparency }`).
- `UILabs.CreateFusionStory(info, story)`, `CreateVideStory`, `CreateReactStory` build the same tables with types.
- A story with no library uses `render = function(props) ... return cleanup end` instead of `story`, and can listen
to controls with `props.subscribe(function(values, infos) end)`.
## Storybooks
Group stories so they don't land in "Unknown Stories":
```luau
-- Game.storybook.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
return {
name = "Game UI",
storyRoots = { ReplicatedStorage.Client.UI },
groupRoots = true, -- optional: one folder per root
}
```
Subfolders under a root become folders in the plugin.
## The UI Labs environment
UI Labs runs each story in a sandbox with its own `require`, `script` and `_G`, so edits reload without a cached
module. `UILabs.Environment.IsStory()` tells code it is inside a story, and `UILabs.Environment.GetJanitor()` gives a
Janitor that is cleaned up on unmount. React Lua recommends Dev Mode for storybooks: set `_G.__DEV__ = true` on the
first line of the story, before React is required.
## Mistakes to avoid
- A component that calls `Players.LocalPlayer`, `WaitForChild` on PlayerGui, or a remote. It hangs or errors in the
story. Move that to the screen that uses the component.
- No cleanup: Fusion scopes not cleaned, React roots not unmounted, Vide not destroyed. Each reload leaks a copy.
- Giving a story a `.server` or `.client` suffix. Stories must be ModuleScripts.
- Testing only the happy path. Add controls or extra stories for long text, empty data and small screens.
- Treating a story as the test of gameplay. Stories check layout and states; remotes and server logic still need a
playtest.