Forest Logo
search
package_2

roblox-ui-kit

By @hurreman

Roblox

Mirrored from Wally

roblox-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.
  • false counts as a value, so stroke = false means 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

SectionWhat it drives
colorsNamed 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".
fontsFont values by key. display and body are required.
textText 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, radiusScales: xs to xxl, sm to pill. Wherever a theme or a component takes a size, it takes a number or one of these keys.
surfacesSurface tokens (below). Required: panel, button, tab, tabActive, tooltip, track, fill, toast.
blockvariant ("flat", "studded" or "image"), studTile, face (the image variant's surface), lip, and style (the Block.Style numbers).
windowWindow'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.
dialogueDialogueBox and GuideBubble colours: panel, nameplate, choice, highlight, disabled, close, portrait. Also charsPerSecond.
toastkindColors (kind → colour), defaultColor, band, top, maxWidth.
rarityRarity 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).
  • Colors and Fonts: 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:

  • scaleType is "Slice" when sliceCenter is 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. So Block { 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

PresetLook
BaseNeutral dark: BuilderSans, flat surfaces, flat blocks (no studs). It's the default.
MagicalNeighborhoodMagical 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.BubbleTooltips. 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.newModel 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.
  • paused holds announcements back. focus lets only focusKinds through, and when focus ends, only the fresh ones are played.
  • A reminder (a toast with about) is dropped, or leaves early, once still(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 SelectionGroup and SelectionBehavior = 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 / observeOnboarding arrows find UI by id, not by instance name: ref = Kit.useArrowTarget("shop").
Kit.Signal, Kit.Slice, Kit.SubscribeThe 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.spec checks that src/ 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 setState every 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/run runs 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, every require(script...) path resolves, every export loads, and every component has a story. lune run tests/run Toast filters by spec name.
  • rojo serve dev.project.json maps the kit to ReplicatedStorage.RobloxUIKit with 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

check_circle

Safe for commercial use

Automated license review — not legal advice.