Skip to content

Stories and preview

A story is a small module that renders one piece of UI, with controls to vary it. Lucy previews stories in Studio without pressing Play, and the same story doubles as a smoke test.

Write a story

Name a ModuleScript <Name>.story anywhere in your game and return a table:

-- Shop/ItemCard.story.luau
return {
    group = "Shop",
    description = "One item in the shop grid.",
    width = 320,
    controls = {
        name = "Rocket boots",
        price = { min = 0, max = 5000, step = 50, default = 750 },
        rarity = { "common", "rare", "legendary" },
        owned = false,
    },
    render = function(ctx)
        local Lucy = ctx.Lucy
        local c = ctx.controls
        return Lucy.Panel.new({
            title = c.name,
            description = function() return `{c.rarity()} · ${c.price()}` end,
            footer = {
                Lucy.Button.new({
                    text = function() return if c.owned() then "Equip" else "Buy" end,
                    variant = function() return if c.owned() then "outline" else "default" end,
                }),
            },
        })
    end,
}
FieldWhat it does
titleName in the list; defaults to the module name without .story
groupSidebar group ("Stories")
descriptionOne line shown with the controls
layout"centered" (default), "padded" or "fullscreen" (scenes)
widthColumn width for centred stories (360)
controlsValues to vary, see below
render(ctx)Builds the UI and returns what to show

ctx holds Lucy (the package, so stories never hard-code a require path), app (the story’s own App), target (the frame to fill) and controls. Return an instance, a component handle or a list of them; anything returned without a parent goes into target.

Controls

Each control’s kind comes from its value:

ValueKindDefault
falsebooleanthe value
"text"stringthe value
3numberthe value
{ "sm", "md", "lg" }choicethe first option
{ options = { ... }, default = "md" }choicedefault
{ min = 0, max = 100, step = 5, default = 40 }numberdefault

ctx.controls.<name> is a writable ref. Every component state key accepts a ref, so checked = ctx.controls.checked binds a Switch both ways: flip the switch and the control follows. For props that are not state keys (Stack’s direction, say), read the control inside a getter child so that part is rebuilt when it changes.

Preview in edit mode

From the command bar or the Studio MCP, with your stories anywhere under ReplicatedStorage:

local Lucy = require(game.ReplicatedStorage.Packages.Lucy)
Lucy.Preview.show("ItemCard", { theme = "light", device = "mobile" })
Lucy.Preview.clear()

The story renders into StarterGui, so Studio draws it in the viewport. Gui input does not work in edit mode; use Play for clicking around.

Preview.show options: root (where to look for stories), theme, density, device (desktop, mobile, tablet, console), baseColor, primary and controls (initial values).

Browse in Play

examples/Showcase.client.luau is a story browser built with Lucy: a sidebar of stories, the canvas, generated controls, a theme switch and a device picker. Copy it into StarterPlayerScripts and point root at your stories folder.

In code and specs

local mounted = Lucy.Preview.mount(story, { theme = "dark", device = "console" })
mounted:set("rarity", "legendary")
mounted:values()      --> { name = ..., price = ..., rarity = "legendary", owned = false }
mounted.error         --> nil, or the message if render failed
mounted:destroy()     -- removes everything the story made, and its App

Lucy.Preview.find(root) lists every story under a folder, and Lucy.Preview.controlsOf(story) lists its controls. A spec that mounts every story in every theme catches broken stories early; Lucy’s own is tests/Stories.spec.luau.