The site is under maintenance, so logging in is temporarily unavailable. All resources and the MCP still work.

AI Skills

UI stories with UI Labs and Hoarcekat

By Roblox Vibecoders · roblox-ui-stories

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.

Install it

Terminal
mkdir -p ~/.claude/skills/roblox-ui-stories && curl -fsSL https://robloxvibecoders.com/skills/roblox-ui-stories/SKILL.md -o ~/.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.