Start typing to search packages!
roblox-ui-kit
By @hurreman
Roblox
Mirrored from Wallyroblox-ui-kit
hurreman/roblox-ui-kit is the shared React UI kit for hurreman's Roblox games (Magical Neighborhood, Slime Nebula). It's built on jsdotlua React 17.2.1.
It gives you:
- Themeable components. Each game keeps its own look, and a surface can be a flat colour or 9-slice image art.
- Motion: UIAnimator, staggers and the panel pop.
- A panel host with focus mode and gamepad selection.
- Toasts, driven by a push API.
- Store hooks that work with any change-signalling client store.
Most of it comes from Magical Neighborhood's src/client/UI. The rest is new: Tabs, ProgressBar, virtualised List/Grid, Tooltip, selection groups, Viewport, PanelHost and the state helpers.
Install
wally.toml:
[place]
shared-packages = "game.ReplicatedStorage.Packages"
[dependencies]
UIKit = "hurreman/roblox-ui-kit@0.1.0"
React = "jsdotlua/react@17.2.1"
ReactRoblox = "jsdotlua/react-roblox@17.2.1"
Run wally install, then map Packages into ReplicatedStorage in the Rojo project. The kit finds React next to itself (Wally links it), or else in ReplicatedStorage.Packages.
Quick start
local Kit = require(ReplicatedStorage.Packages.UIKit)
local React, ReactRoblox = Kit.React, Kit.ReactRoblox
local e = React.createElement
Kit.configure({ extends = Kit.Presets.Base, colors = { accent = Color3.fromRGB(80, 220, 200) } })
local panels = Kit.PanelRegistry.new()
panels:register("Rewards", { component = RewardsPanel, animatesClose = true })
local function App()
local scale = Kit.useViewportScale()
return e("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1 }, {
Scale = e("UIScale", { Scale = scale }),
Tips = e(Kit.Tooltip.Provider, {}, {
Panels = e(Kit.PanelHost.Host, { registry = panels }),
Toasts = e(Kit.Toasts.View, { feed = toasts }),
}),
})
end
Use one root: a single ReactRoblox.createRoot in a ScreenGui with ResetOnSpawn = false and ZIndexBehavior = Sibling.
Everything on Kit loads the first time you touch it. So require is cheap, and a game only pays for the parts it uses.
Theming
There's one current theme per client. Kit.configure(theme) merges a partial theme over the theme it extends. When extends is left out, that's Kit.Presets.Base.
configure checks the result straight away. A missing required token, an unknown colour key, or a surface or text role that doesn't resolve raises an error then, not halfway through a render.
Kit.configure({ extends = Kit.Presets.MagicalNeighborhood, rarity = HouseTiers.RarityColors }) -- Magical Neighborhood
Kit.configure(SlimeNebulaTheme) -- a game's own table
Merge rules:
- Sections merge key by key.
- A surface token replaces the token of the same name. A 9-slice panel doesn't inherit the flat panel's stroke and corner.
falsecounts as a value, sostroke = falsemeans no outline.
Components that call Kit.useTheme() re-render when configure runs again. Plain-function primitives (Kit.Text, Kit.Panel, Block.Surface) pick up the new theme on their parent's next render.
To follow a runtime theme, as Slime Nebula's Theme.lua does with its Theme.Changed signal, call configure from that signal:
local function apply() Kit.configure(SlimeNebulaTheme.toKit()) end
apply()
SlimeNebulaTheme.Changed:Connect(apply)
Sections
| Section | What it drives |
|---|---|
colors | Named colours. The kit reads these semantic keys: text, textDim, stroke, panel, panelLight, accent, positive, negative, plus warning, close and disabled from Base. Any other keys are the game's own palette. A string value is an alias: accent = "gold". |
fonts | Font values by key. display and body are required. |
text | Text roles for Kit.Text { role = ... }. Each role is { font = key, color? = key, strokeColor?, strokeSize?, maxSize? }. Base has display, title, body, stat, label and dim. An unknown role falls back to display. |
space, radius | Scales: xs to xxl, sm to pill. Wherever a theme or a component takes a size, it takes a number or one of these keys. |
surfaces | Surface tokens (below). Required: panel, button, tab, tabActive, tooltip, track, fill, toast. |
block | variant ("flat", "studded" or "image"), studTile, face (the image variant's surface), lip, and style (the Block.Style numbers). |
window | Window's layout constants: ribbon height, title icon, close button, padding, fit margins, dim, panelColor. panelSurface draws the panel from a surface, such as a 9-slice frame, instead of a block. |
dialogue | DialogueBox and GuideBubble colours: panel, nameplate, choice, highlight, disabled, close, portrait. Also charsPerSecond. |
toast | kindColors (kind → colour), defaultColor, band, top, maxWidth. |
rarity | Rarity name → colour, used by ToastItem. Games pass their own ladder. |
Kit.Theme also exposes:
color(ref),space(v),radius(v),surface(ref, over?),role(name?),current(),onChanged(fn).ColorsandFonts: live mirrors with every colour resolved.Resolve: the pure resolver.
Surfaces
A surface token is either flat or image (9-slice). Every surface name and every surface prop takes either kind.
Flat:
panel = {
color = "panel", -- a colour key or a Color3
transparency = 0,
corner = "md", -- px or a radius key
stroke = { color = "stroke", thickness = 2, transparency = 0 },
gradient = { rotation = 90, top = 0.12, bottom = 0.12 }, -- the fill lightened/darkened (or colors = { a, b })
padding = "sm", -- adds a UIPadding
}
Image (9-slice). Setting image makes a token an image surface:
Kit.configure({
surfaces = {
panel = {
image = "rbxassetid://1234567890", -- the frame art
sliceCenter = Rect.new(48, 48, 208, 208),
sliceScale = 0.5, -- shrink the corners for a 2x texture
imageRectOffset = Vector2.new(0, 0), -- optional: one cell of a sprite sheet
imageRectSize = Vector2.new(256, 256),
tint = "panel", -- ImageColor3 (colour key or Color3)
padding = 14, -- keep content off the art's border
},
window = { extends = "panel", sliceScale = 0.75 }, -- inherit, then tweak
buttonPrimary = { extends = "panel", tint = "accent" },
},
block = { variant = "image", face = { image = "rbxassetid://...", sliceCenter = Rect.new(20, 20, 44, 44), sliceScale = 1 } },
window = { panelSurface = "window" },
})
How image surfaces behave:
scaleTypeis"Slice"whensliceCenteris set, and"Stretch"otherwise.- An image surface has no fill behind the art unless you give it a
color. - When a component passes a
color, it tints an image surface and fills a flat one. SoBlock { color = ... }works with both kinds. - A token can inherit from another with
extends = "<surface name>". A flat child of an image parent drops the image.
To draw a surface yourself: Kit.Surface.render { surface, over?, color?, tint?, size, onClick?, sink?, native?, children }, or e(Kit.Surface.Surface, ...), which forwards ref.
Presets
| Preset | Look |
|---|---|
Base | Neutral dark: BuilderSans, flat surfaces, flat blocks (no studs). It's the default. |
MagicalNeighborhood | Magical Neighborhood's current look: LuckiestGuy + Nunito, the purple panels, the studded blocks, and its Window and dialogue colours. Its old palette keys (gold, orange, green, red, purple, white, dim) are kept as aliases of the semantic ones. |
The stories add a third, story-only theme, NineSlice, with every surface made of 9-slice art. Paste your own art into its SliceImage control.
Components
All views are pure: no remotes, no game modules. Data and callbacks come in as props.
Kit.Text { text, role?, size?, color?, strokeSize?, maxSize?, rich?, textSize?, autoHeight?, alignY?, ... } | Outlined text in a text role. By default it is scaled to fit its box (titles, stats, labels). For multi-line body copy give textSize: a fixed size that wraps; add autoHeight (with a height-0 size) to grow the box with the text. Kit.Theme.stat("+30%", "money") makes rich text with the figure in a heavier weight. |
Kit.Panel { surface? = "panel", color?, radius?, strokeSize?, onClick?, sink?, ... } | A themed panel. sink = true swallows clicks. |
Kit.Block.Surface / Kit.Block.Button { color, icon?, label?, layout?, variant?, onClick?, disabled?, disabledColor?, ... } | The chunky block (lip, sink on press, icon hover). A disabled button ignores hover, press and clicks and turns the theme's disabled colour (disabledColor overrides it, false keeps the normal colour). Kit.Block.Style holds the live theme.block.style. |
e(Kit.Window, { title, icon?, color?, size: Vector2, onClose, subtitle?, header?, dim?, closeOnBackdrop?, closing?, closeTo? }, children) | The modal shell. It pops in, and closing plays a wind-up and shrink before onClose runs. It also shrinks itself to fit small screens. subtitle adds a dim caption line at the top of the body (children go below it). |
Kit.Backdrop.element { dim?, onActivated?, ref?, zIndex? } | The modal dim. It overshoots the safe area. |
Kit.IconLabel { image, text, size, ... } | An icon and text laid out as one group. |
e(Kit.PointArrow, { position, label?, rightEdge?, bob? }), e(Kit.Spotlight, { hole, label?, bob? }) | Onboarding pointers. |
Kit.Burst.emit(parent, origin, { glyphs, colors?, count?, distance?, gravity? }) | Fire-and-forget particle bursts. |
e(Kit.GuideBubble, { lineId, speaker, text, portrait?, fallbackIcon?, onDismiss }) | The tutorial guide's speech bubble. |
e(Kit.DialogueBox, { speaker, text, nodeId, choices: { ChoiceView }, onChoose, onClose }) | NPC dialogue. Each ChoiceView is { Index, Text, Disabled?, DisabledReason?, Highlighted? }. |
e(Kit.Tabs, { tabs = { { id, label, icon?, badge? } }, selected, onSelect, vertical?, ... }) | A tab strip. L1 and R1 cycle the tabs on a controller. |
e(Kit.ProgressBar, { value, tween?, label?, ticks?, vertical?, track?, fill?, fillColor? }) | value is a number or a binding. A binding moves the fill every frame with no re-render. |
e(Kit.List, { items, itemHeight, renderItem, keyOf?, overscan? }), e(Kit.Grid, { items, cellSize, renderItem, columns?, stretch?, gap? }) | Virtualised scrolling: only the rows in view are mounted. |
e(Kit.Tooltip.Provider, ...), Kit.Tooltip.use(content), Kit.Tooltip.merge(handlers, props), Kit.Tooltip.Bubble | Tooltips. They follow the mouse, or sit above the selected element on a gamepad. |
e(Kit.ErrorBoundary, { name, onError?, fallback? }, children) | Catches a render error in its subtree: warns, calls onError once (deferred), and shows fallback (default nothing). A remount starts fresh. PanelHost wraps every panel in one. |
e(Kit.Viewport.View, { model, yaw?, pitch?, zoom?, fov?, spin?, surface? }), Kit.Viewport.pool, Kit.Viewport.Pool.new | Model previews framed to fit. Clones are pooled per template, and spin runs through a ref. |
Kit.Toasts.new(config?), toasts:push / notify, e(Kit.Toasts.View, { feed, paused?, focus?, top?, rarityColors?, renderAnnouncement? }), e(Kit.ToastItem, ...) | Notifications (below). |
Toasts
The queue (Kit.Toasts.Queue) has no store wiring. The game pushes into it:
local toasts = Kit.Toasts.new({
kindTimes = { hatch = 4.2 }, -- fixed durations per kind
focusKinds = { tutorial = true }, -- what may interrupt focus mode
uncollapsedKinds = { hatch = true }, -- every one gets its own turn
still = function(about) return ToastSubjects.still(about) end, -- reminders that go stale
})
Net.event("Notify").OnClientEvent:Connect(function(kind, text, rarity, about)
toasts:notify(kind, text, { rarity = rarity, about = about })
end)
These are Magical Neighborhood's rules:
- Announcements show one at a time, sized to how long the text takes to read. A repeat collapses into the one before it as ×N.
- Feedback kinds (
error,collect) show immediately, beside any announcement. pausedholds announcements back.focuslets onlyfocusKindsthrough, and when focus ends, only the fresh ones are played.- A reminder (a toast with
about) is dropped, or leaves early, oncestill(about)returns false.
renderAnnouncement(item, leaving) draws special kinds yourself, such as Magical Neighborhood's hatch card.
Panels, focus and gamepad
local panels = Kit.PanelRegistry.new()
panels:register("Shop", { component = ShopPanel, animatesClose = true }) -- focus = true by default
panels:register("Map", { component = MapPanel, focus = false, selection = false })
panels:open("Shop", { tab = "gems" }, "dock") -- args, source
e(Kit.PanelHost.Host, { registry = panels, focusMode? = true, blur? = true, panelProps? }) renders the one open panel. Each panel gets { name, args, closing, onClose, close, requestClose }. Pass closing and onClose through to Kit.Window. To close from inside the panel (e.g. after a successful action), call requestClose(): it plays the close animation. close() skips it.
When a panel with animatesClose = true is replaced or closed, it stays mounted with closing = true until its close animation calls onClose.
Errors stay contained. Each panel renders inside a Kit.ErrorBoundary. A panel that throws while rendering is warned about ([roblox-ui-kit] panel <name> crashed while rendering), closed in the registry with source "error" and unmounted. Other panels and the root keep running. Wrap other independent regions (HUD widgets, world boards) yourself: e(Kit.ErrorBoundary, { name, onError?, fallback? }, children).
While a panel with focus is open, Kit.FocusMode blurs the world and hides the touch controls. Each panel sits in a Kit.SelectionGroup.
Hooks: Kit.PanelHost.usePanel(registry) -> name, args and Kit.PanelHost.useIsOpen(registry, name).
Bridging to a game's modal bus. Every change carries a source, so a bridge can ignore the changes it made itself. Slime Nebula's UnifiedModal / ActionEvents would bridge like this:
panels:onPanelChanged(function(name, previous, change)
if change.source == "bus" then return end -- we did that: don't echo it back
if previous then ActionEvents.ModalClosed:Fire(previous) end
if name then ActionEvents.ModalOpened:Fire(name) end
end)
ActionEvents.ModalOpened.Event:Connect(function(legacyName)
if not panels:get(legacyName) then panels:close(nil, "bus") end -- a legacy panel opened: close ours
end)
e(Kit.SelectionGroup, { active?, default?, restore?, gamepadOnly?, confine? }, children):
- Confines D-pad navigation to the group, using
SelectionGroupandSelectionBehavior = Stop. - Selects a default when the group opens: a ref, a descendant's name, or the top-left selectable.
- Restores the earlier selection when it closes. Nested groups restore in order (
Kit.Selection.Stack).
By default it only takes the selection when the player is on a controller. In Studio, set the workspace attribute ForceGamepad = true to test without a pad.
Kit.Input has:
prefersGamepad(),prefersKeyboard(),touchControlsShown(),mode()padGlyph(keyCode?)onModeChanged(fn),useInputMode()useViewportScale({ design?, min?, max? })
Kit.TouchButtons.place(action, "left" | "above") puts ContextActionService buttons next to the jump button.
State hooks
Kit.useSignal(store, read?, equal?) | Re-renders when the store fires. It understands OnChanged(fn) -> disconnect (Slime Nebula's clients), an OnChanged or Changed signal (RBXScriptSignal, BindableEvent, any :Connect), subscribe(fn) (Magical Neighborhood's Store), or a signal on its own, including a bare RBXScriptSignal such as bindable.Event (fields are probed safely, so a strict signal never throws). read defaults to store.state, then store:get(), else nil. |
Kit.useSlice(state, keys) | A table of just keys, rebuilt only when one of them changes by reference. Give it to React.memo children. |
Kit.useClock({ now?, step? }) -> binding, Kit.useClock.at(deadline?, { now?, slack? }) | Countdowns without per-frame re-renders. useClock is a binding of now() written once per whole step (map labels from it). useClock.at re-renders once just after deadline, for state that flips when a countdown ends. Server timers: now = function() return workspace:GetServerTimeNow() end. Pure maths: Kit.Clock. |
Kit.useSlice.fromStore(store, keys) | The same, subscribed straight to a store. The component re-renders only when one of its own keys changes. |
Kit.DeltaStore.new() | A snapshot mirror that applies a full state or a delta: :apply(changes, full?, removed?), :set, :reset, .Changed. Plus DeltaStore.diff(prev, next) for the server side, and DeltaStore.connect(store, syncRemote, helloRemote?). |
Kit.useArrowTarget(id, ownRef?), Kit.useArrowTarget.instance(id), Kit.ArrowTargets.get / observe | Onboarding arrows find UI by id, not by instance name: ref = Kit.useArrowTarget("shop"). |
Kit.Signal, Kit.Slice, Kit.Subscribe | The pure helpers underneath. |
Motion: Kit.UIAnimator, Kit.useStagger(trigger, collect, preset?, options?) and Kit.usePanelPop. Inside React, pass wrap = false (useStagger already does). For values that change every frame, use bindings or refs, never setState.
Other hooks: Kit.useAttrs, Kit.useScreenArea, Kit.useTopBarPlacement.
Conventions
- Views are pure. No remotes, no game modules: data comes in as props.
tests/specs/Static.specchecks thatsrc/never requires game code. - Every component has a UI Labs story in
stories/, with mock props and style controls. Each story also gets theme controls: Preset, Accent, and the 9-slice art. - Motion uses UIAnimator (
wrap = false) or TweenService on refs. - Per-frame values use bindings or refs, never
setStateevery frame.
Development
aftman install
wally install
lune run tests/run
rojo build dev.project.json -o build/dev.rbxl
rojo serve dev.project.json
lune run tests/runruns the unit specs: theme resolution, the toast queue, DeltaStore, Slice and Subscribe, the panel registry, the selection stack, arrow targets, the virtual range maths and the viewport pool. It also runs static checks: every file compiles, everyrequire(script...)path resolves, every export loads, and every component has a story.lune run tests/run Toastfilters by spec name.rojo serve dev.project.jsonmaps the kit toReplicatedStorage.RobloxUIKitwith the stories beside it, for UI Labs.
Publishing
Not published yet. To publish, bump version in wally.toml, then:
wally login
wally publish
Consumers then add UIKit = "hurreman/roblox-ui-kit@<version>".
Package Details
Install command (Click to copy)
Version
0.1.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
