A decorator-based UI framework for roblox-ts that manages app registration, parent/child relationships, visibility state, rules, and fade transitions — built on top of Vide.
Issues or bugs? Tag
@Xynzor@Loner71xin the roblox-ts Discord server.
npm install @rbxts/forge
⚠️ Required. This file must exist at the root of your project before anything else. It defines the global type system Forge uses to enforce type-safe app names, groups, and props across your entire codebase.
declare global {
type AppGroups = "HUD" | "Menus";
type AppNames = "Inventory" | "Settings" | "Tooltip";
type AppProps = {
player: Player;
};
}
export {};| Type | Description |
|---|---|
AppGroups |
Union of every group name used in your project |
AppNames |
Union of every app/component name |
AppProps |
Shared props passed to every component (e.g. player) |
Forge injects a global AppForge namespace into your project automatically. It provides utility types so you never need to import anything from Vide or Forge just to annotate your components.
| Type | Description |
|---|---|
AppForge.Node |
Return type for render() in both Args and ChildArgs — equivalent to JSX.Element without needing to import it from Vide |
AppForge.Props.Class |
The type of this.props inside any component — your AppProps plus the full px and screen utilities |
AppForge.Props.Main |
Full props object passed to render() and story() — useful when building utilities around the render layer |
AppForge.Props.Config |
The optional config object for overriding px defaults |
// Typing render() — no Vide import needed
export default class MyApp extends Args {
render(): AppForge.Node {
return <frame />;
}
}
// Typing a standalone component that receives forwarded props
function MyComponent(props: { appProps: AppForge.Props.Class }) {
const { px, screen } = props.appProps;
}
// Typing a utility that wraps the render layer
function myHelper(props: AppForge.Props.Main, config?: AppForge.Props.Config) { ... }Registers a class as a root-level UI app. Each name + group combination must be globally unique — Forge throws a runtime error on duplicates.
@App({
name: AppNames, // Required
group?: AppGroups, // Defaults to "None"
visible?: boolean, // Defaults to false
zIndex?: number, // Defaults to 1
rules?: {}, // Optional — reserved for future rule types
})Registers a class as a child UI app linked to a parent. The child is rendered inside the parent's container.
If anchor: true, the child's instance is placed inside a transparent clone of the parent's rendered frame — allowing the child to position itself relative to the parent's layout without inheriting its properties.
@ChildApp({
name: AppNames, // Required
group?: AppGroups, // Defaults to "None"
visible?: boolean, // Defaults to false
zIndex?: number, // Defaults to 0
rules: { // Required
parent: AppNames, // Required — the parent app's name
parentGroup?: AppGroups, // Defaults to "None"
anchor?: boolean, // Defaults to false
}
})Wraps an app in a spring-driven transparency fade using a CanvasGroup. Must be placed above @App or @ChildApp.
@Fade(0.25, 0.75)| Parameter | Default | Description |
|---|---|---|
period |
0.5 |
Spring period in seconds — lower is faster |
dampeningRatio |
0.75 |
How quickly the spring settles — 1 = no overshoot |
Decorator order matters — @Fade must always be the outermost decorator:
@Fade(0.25) // ← outermost
@App({ ... }) // ← innermost
export default class MyApp extends Args { ... }export default class MyApp extends Args {
render(): AppForge.Node { ... }
}export default class MyChild extends ChildArgs {
render(): AppForge.Node { ... }
}Both expose the following on this:
| Property | Type | Description |
|---|---|---|
forge |
AppForge |
The Forge controller — open, close, toggle, bind other apps |
source |
Source<boolean> |
Reactive visibility state for this app |
name |
AppNames |
This app's registered name |
group |
AppGroups |
This app's registered group ("None" if unset) |
props.px |
px |
Pixel-scaling utility (see px & screen) |
props.screen |
Source<Vector2> |
Reactive current screen/viewport size |
props.[...AppProps] |
— | Everything from your global AppProps (e.g. props.player) |
ChildArgs additionally exposes:
| Property | Type | Description |
|---|---|---|
parentSource |
Source<boolean> |
Reactive visibility state of the parent app |
MendArgs allows you to extend your apps with type-safe custom properties that can be accessed via context hooks.
import { App, Args, MendArgs } from "@rbxts/forge";
// Define custom properties
type CustomProps = {
color: Color3;
title: string;
};
@App({
name: "CustomApp",
group: "UI",
})
export default class CustomApp extends Args implements MendArgs<CustomProps> {
public color = Color3.fromRGB(255, 100, 100);
public title = "My Custom App";
render(): AppForge.Node {
const { px } = this.props;
return (
<frame BackgroundColor3={this.color}>
<textlabel Text={this.title} TextSize={px(20)} />
</frame>
);
}
}Access custom properties in child components using useAppContext:
import { useAppContext } from "@rbxts/forge";
type CustomProps = {
color: Color3;
title: string;
};
function ChildComponent() {
const { color, title, props } = useAppContext<CustomProps>();
const { px } = props;
return (
<frame BackgroundColor3={color}>
<textlabel Text={title} TextSize={px(16)} />
</frame>
);
}The generic parameter merges your custom properties with the base context properties (forge, props, source, name, group), giving you full type safety.
this.forge (and the AppForge instance you create) exposes these methods:
forge.open("Inventory", "HUD") // Set visibility to true
forge.close("Inventory", "HUD") // Set visibility to false
forge.toggle("Inventory", "HUD") // Flip current visibility
forge.set("Inventory", "HUD", true) // Set to an explicit boolean value
// Sync an external Vide Source<boolean> into an app's visibility.
// The external source drives the internal one — rules still apply on top.
forge.bind("Inventory", "HUD", mySource)
// Render registered apps into a Vide tree (used in-game)
forge.render({ props: { props, renders } })
// Render into a story container (used with UI-Labs)
forge.story({ props, target, renders, config })The group parameter defaults to "None" on all methods if omitted.
Note on
bind:bindcreates a one-way sync — the external source pushes into the internal one. Rules still fire on top of whatever the bound source sets. This means if a rule closes an app, the external source stays at its current value and will re-apply when it next changes.
Rules are logic that Forge automatically applies whenever an app's visibility changes. They are set up via setupRuleEffects after rendering and fire reactively through Vide's effect system.
The only active rule currently. It enforces a visibility relationship between parent and child apps:
When a parent closes:
- Each child's current visibility is cached
- All children are closed
When a parent opens:
- Each child is restored to its cached visibility state
While a parent is closed:
- If a child's visibility is changed (e.g. via
forge.setorforge.bind), the cache updates to reflect the new desired state - When the parent opens, the child will restore to this updated value
This means the cache always represents what the child wants to be when the parent is visible again — not just what it was when the parent closed.
// Example behavior:
// Parent open, Child open → close parent → child closes, cache = true
// While parent closed → forge.close("Child") → cache updates to false
// Open parent → child stays closed (cache was false)
// Parent open, Child open → close parent → child closes, cache = true
// While parent closed → forge.open("Child") → cache updates to true
// Open parent → child reopens (cache was true)Forge exposes two Vide contexts that let components deep in your tree access app data without prop drilling.
Use inside apps decorated with @App. Pass this as the value to give all descendants access to forge, props, source, name, group, and any custom properties defined via MendArgs:
import AppForge, { Args, App, Fade, AppContext } from "@rbxts/forge";
import Vide, { Provider } from "@rbxts/vide";
@Fade(0.25)
@App({ name: "Inventory", group: "HUD", visible: true })
export default class Inventory extends Args {
render(): AppForge.Node {
return (
<frame Size={UDim2.fromScale(1, 1)}>
<Provider context={AppContext} value={this}>
{() => <TooltipButton />}
</Provider>
</frame>
);
}
}Use inside apps decorated with @ChildApp when descendants need access to parentSource:
import { ChildArgs, ChildApp, ChildAppContext } from "@rbxts/forge";
import Vide, { Provider } from "@rbxts/vide";
@ChildApp({ name: "Tooltip", group: "HUD", rules: { parent: "Inventory", parentGroup: "HUD" } })
export default class Tooltip extends ChildArgs {
render(): AppForge.Node {
return (
<frame>
<Provider context={ChildAppContext} value={this}>
{() => <TooltipContent />}
</Provider>
</frame>
);
}
}Call inside any component nested under an AppContext provider. Returns the full Args instance plus any custom properties:
import { useAppContext } from "@rbxts/forge";
type CustomProps = {
backgroundColor: Color3;
};
export function TooltipButton() {
const { forge, props, source, name, group, backgroundColor } = useAppContext<CustomProps>();
const { px } = props;
return (
<textbutton
BackgroundColor3={backgroundColor}
Size={() => UDim2.fromOffset(px(100), px(40))}
Activated={() => forge.toggle("Tooltip", "HUD")}
/>
);
}Call inside any component nested under a ChildAppContext provider. Returns the full ChildArgs instance including parentSource:
import { useChildAppContext } from "@rbxts/forge";
export function TooltipContent() {
const { forge, props, source, parentSource } = useChildAppContext();
const { px } = props;
return (
<frame Size={() => UDim2.fromOffset(px(200), px(100))} />
);
}Both hooks throw a descriptive runtime error with a traceback if called outside their respective provider.
| Hook | Context | Returns |
|---|---|---|
useAppContext<T>() |
AppContext |
forge, props, source, name, group, + custom properties from T |
useChildAppContext<T>() |
ChildAppContext |
forge, props, source, name, group, parentSource, + custom properties from T |
px and screen are injected into this.props automatically. You do not need to call usePx() manually — Forge initializes it internally when render() or story() is called.
Scales a pixel value relative to the current viewport using a base resolution of 1920×1080 and an equal blend of width/height scaling. Minimum scale defaults to 0.5.
const { px } = this.props;
px(200) // scaled integer (math.round)
px.scale(200) // unrounded float
px.even(200) // rounded to nearest even number
px.floor(200) // math.floor
px.ceil(200) // math.ceilA reactive Source<Vector2> holding the current size of the render target. Updates automatically when the viewport or GuiObject resizes.
const { screen } = this.props;
screen() // → e.g. Vector2.new(1920, 1080)Both render() and story() accept an optional config to override px defaults:
type Config = {
px?: {
target?: GuiObject | Camera // defaults to Workspace.CurrentCamera
resolution?: Vector2 // defaults to Vector2.new(1920, 1080)
minScale?: number // defaults to 0.5
}
}Both render() and story() accept an optional renders filter to control which registered apps are loaded. name/names and group/groups are mutually exclusive in each pair — the type system enforces this.
renders: { name: "Inventory" }
renders: { names: ["Inventory", "Settings"] }
renders: { group: "HUD" }
renders: { groups: ["HUD", "Menus"] }
renders: { name: "Inventory", group: "HUD" }
renders: { names: ["Inventory", "Settings"], group: "HUD" }Omitting renders entirely loads all registered apps.
Forge includes a built-in logger for debug output and render timing. Debug mode is off by default — all debug and time calls are silent in production.
import AppForge, { Logger } from "@rbxts/forge";
Logger.setDebug(true); // enable before constructing AppForge| Method | Always fires | Description |
|---|---|---|
Logger.debug(context, message) |
No | Prints only when debug is enabled |
Logger.warn(context, message) |
Yes | Always prints a warning |
Logger.error(context, message) |
Yes | Always throws an error |
Logger.time(context, name, fn) |
No | Times fn and prints result when debug is enabled |
When debug is enabled, Forge automatically logs render timing for every app and total load time:
[Forge][Renders]: "HUD:Inventory" rendered in 0.0003s
[Forge][Renders]: "HUD:Tooltip" rendered in 0.0001s
[Forge][Renders]: Load completed in 0.0008s — 2 app(s) rendered
import type { ForgeProps, ClassProps, RenderProps, StoryProps } from "@rbxts/forge";| Export | Description |
|---|---|
ForgeProps |
Full props object for render() and story() — includes props, forge, config, renders |
ClassProps |
What this.props looks like inside a component — your AppProps plus px and screen |
RenderProps |
The renders filter object |
StoryProps |
Props for the Story component |
import AppForge, {
// Decorators
App, ChildApp, Fade, MendArgs,
// Base classes
Args, ChildArgs,
// Contexts
AppContext, ChildAppContext,
// Hooks
useAppContext, useChildAppContext,
// Story component
Story,
// Logger
Logger,
} from "@rbxts/forge";// client/controllers/app.ts
onInit() {
const forge = new AppForge();
const props = { player: Players.LocalPlayer } as AppProps;
mount(() => (
<screengui Name="App Tree" ResetOnSpawn={false} IgnoreGuiInset>
<forge.render props={{ props, renders: { group: "HUD" } }} />
</screengui>
), Players.LocalPlayer.WaitForChild("PlayerGui"));
}Use forge.story() for previewing components with UI-Labs — a storybook plugin for Roblox. Get the plugin on the Roblox Store and the roblox-ts package on npm.
Forge exports a Story component you can use directly in your story files. It handles AppForge construction and the forge.story() call for you.
⚠️ Flamework.addPaths()must be a string literal pointing to your apps folder and must be called beforeStoryrenders. Flamework transforms it at compile time — you cannot pass a dynamic string. This means it must live in your project, not inside the package.
export interface StoryProps {
debug?: boolean; // Enable debug logging (default: false)
props: AppProps; // Your global app props
target: GuiObject; // Target container for rendering
render?: RenderProps; // Optional filter for which apps to render
callback?: (props: AppProps, forge: AppForge) => void; // Optional callback after render
}// src/client/interface/stories/inventory.story.tsx
import { Boolean, ControlGroup, CreateVideStory, type InferVideProps } from "@rbxts/ui-labs";
import { Flamework } from "@flamework/core";
import { Story } from "@rbxts/forge";
import Vide from "@rbxts/vide";
// Must be a string literal — points to your apps folder
Flamework.addPaths("src/client/interface/apps");
const controls = {
Inventory: ControlGroup({
visible: Boolean(true),
}),
Tooltip: ControlGroup({
visible: Boolean(false),
}),
};
const story = CreateVideStory(
{ vide: Vide, controls },
(props: InferVideProps<typeof controls>) => (
<Story
props={{ player: game.GetService("Players").LocalPlayer }}
target={props.target}
render={{ name: "Inventory", group: "HUD" }}
debug // Enable debug logging to see render times
callback={(_, forge) => {
forge.bind("Inventory", "HUD", props.controls.Inventory.visible);
forge.bind("Tooltip", "HUD", props.controls.Tooltip.visible);
}}
/>
),
);
export = story;forge.bind() wires UI-Labs controls directly to app visibility. Rules still apply on top — so if Inventory closes while Tooltip is open, ParentRule will cache and close Tooltip correctly regardless of the bound control's value.
When debug is enabled, you'll see render timing output in the console:
[Forge][Renders]: "HUD:Inventory" rendered in 0.0003s
[Forge][Renders]: "HUD:Tooltip" rendered in 0.0001s
[Forge][Renders]: Load completed in 0.0008s — 2 app(s) rendered
// src/client/interface/apps/inventory.ts
import AppForge, { App, Args, Fade, AppContext, MendArgs } from "@rbxts/forge";
import Vide, { Provider, spring, source } from "@rbxts/vide";
type InventoryProps = {
backgroundColor: Color3;
};
@Fade(0.25)
@App({
name: "Inventory",
group: "HUD",
visible: true,
})
export default class Inventory extends Args implements MendArgs<InventoryProps> {
public backgroundColor = Color3.fromRGB(40, 40, 40);
render(): AppForge.Node {
const { px } = this.props;
const [position] = spring(
() => UDim2.fromScale(0.5, this.source() ? 0.5 : 1.5),
1,
0.6,
);
return (
<frame
BackgroundColor3={this.backgroundColor}
Size={() => UDim2.fromOffset(px(200), px(200))}
AnchorPoint={new Vector2(0.5, 0.5)}
Position={position}
ZIndex={10}
>
<uicorner CornerRadius={() => new UDim(0, px(15))} />
<Provider context={AppContext} value={this}>
{() => <ToggleButton />}
</Provider>
</frame>
);
}
}
function ToggleButton() {
const { forge, props, backgroundColor } = useAppContext<InventoryProps>();
const { px } = props;
return (
<textbutton
BackgroundColor3={backgroundColor}
AnchorPoint={new Vector2(0.5, 1)}
Position={() => new UDim2(0.5, 0, 1, -px(5))}
Size={() => UDim2.fromOffset(px(100), px(50))}
Activated={() => forge.toggle("Tooltip", "HUD")}
>
<uicorner CornerRadius={() => new UDim(0, px(15))} />
</textbutton>
);
}// src/client/interface/apps/tooltip.ts
import { ChildApp, ChildArgs } from "@rbxts/forge";
import Vide, { spring } from "@rbxts/vide";
@ChildApp({
name: "Tooltip",
group: "HUD",
rules: {
parent: "Inventory",
parentGroup: "HUD",
anchor: true,
},
})
export default class Tooltip extends ChildArgs {
render(): AppForge.Node {
const { px } = this.props;
const [position] = spring(
() => UDim2.fromScale(this.source() ? 0 : 1, 0.5),
0.4,
0.8,
);
return (
<frame
BackgroundColor3={Color3.fromRGB(150, 150, 150)}
Size={() => UDim2.fromOffset(px(100), px(175))}
AnchorPoint={new Vector2(1, 0.5)}
Position={position}
>
<uicorner CornerRadius={() => new UDim(0, px(8))} />
<textbutton
BackgroundColor3={Color3.fromRGB(30, 30, 30)}
AnchorPoint={new Vector2(0.5, 1)}
Position={() => new UDim2(0.5, 0, 1, -px(5))}
Size={() => UDim2.fromOffset(px(100), px(50))}
>
<uicorner CornerRadius={() => new UDim(0, px(15))} />
</textbutton>
</frame>
);
}
}import { App, Args, MendArgs } from "@rbxts/forge";
import { source } from "@rbxts/vide";
type CounterProps = {
count: Vide.Source<number>;
};
@App({ name: "Counter", group: "UI" })
export default class Counter extends Args implements MendArgs<CounterProps> {
public count = source(0);
render(): AppForge.Node {
const { px } = this.props;
return (
<frame
BackgroundColor3={Color3.fromRGB(40, 40, 40)}
Size={UDim2.fromOffset(px(300), px(100))}
Position={UDim2.fromScale(0.5, 0.5)}
AnchorPoint={new Vector2(0.5, 0.5)}
>
<uicorner CornerRadius={new UDim(0, px(12))} />
<textlabel
Text={() => `Count: ${this.count()}`}
Size={UDim2.fromScale(1, 0.5)}
BackgroundTransparency={1}
TextColor3={Color3.fromRGB(255, 255, 255)}
TextSize={px(20)}
/>
<textbutton
Text="Increment"
Size={UDim2.fromScale(1, 0.5)}
Position={UDim2.fromScale(0, 0.5)}
BackgroundColor3={Color3.fromRGB(60, 60, 60)}
TextColor3={Color3.fromRGB(255, 255, 255)}
TextSize={px(16)}
Event={{
Activated: () => this.count(this.count() + 1),
}}
>
<uicorner CornerRadius={new UDim(0, px(12))} />
</textbutton>
</frame>
);
}
}global.d.tswithAppGroups,AppNames, andAppPropsis required — Forge will not work without it.- App
name + groupcombinations must be globally unique — Forge throws a runtime error on duplicates. - Sources are only created for apps that are actually rendered — unrendered apps have no source and no rule effects.
Flamework.addPaths()must be a string literal in your project — it cannot be inside the package or passed as a variable.- Use
AppContextwithuseAppContext()for root apps, andChildAppContextwithuseChildAppContext()for child apps — mixing them will throw a runtime error. - Use
MendArgs<T>to add type-safe custom properties to your apps that are accessible viauseAppContext<T>(). - A test folder is available in the repository for reference implementations.
MIT