-
Notifications
You must be signed in to change notification settings - Fork 0
JSX and Components
@nrbx/react gives you React-style components for Roblox UI. You write TSX, the compiler turns it into Lua calls, and the runtime resolves those calls into Roblox instances.
The core idea is simple:
- Lowercase tags like
<frame>and<textlabel>become Roblox instances. - Uppercase tags like
<MyPanel />call a component function. - The JSX runtime wraps the underlying React element creation process with Roblox-specific behavior.
- You can mix native Roblox props, HTML-style tags, events, and Tailwind-like class names in the same component tree.
roblox-ts compiles TSX into Lua by transforming JSX into calls to a JSX factory. In @nrbx/react, that factory is React.createElement.
This means a component like this:
import React from "@nrbx/react";
function Greeting({ name }: { name: string }) {
return (
<frame BackgroundTransparency={1}>
<textlabel Text={"Hello, " + name} />
</frame>
);
}is effectively compiled to a call roughly like this:
const Greeting = ({ name }: { name: string }) => {
return React.createElement(
"frame",
{ BackgroundTransparency: 1 },
React.createElement("textlabel", { Text: "Hello, " + name }),
);
};The wrapper around React's runtime does a few extra things that regular JSX does not:
- resolves HTML-like tags like
<div>or<button>to Roblox GUI classes - wraps plain string/number children into text elements when needed
- translates web-like event props like
onClickinto RobloxEvent={{ Activated: ... }} - resolves Tailwind-style
classNamevalues into underlying Roblox props
To enable TSX in a roblox-ts project, set the JSX compiler options:
// tsconfig.json
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "React.createElement",
"jsxFragmentFactory": "React.createFragment"
}
}If you use a custom transform or a non-default setup, make sure it points at React.createElement and React.createFragment instead of a different runtime.
The JSX factory is the wrapper around React's element creation. Its runtime signature is:
createElement(type, config, ...children)In @nrbx/react, the implementation still behaves like React, but it adds Roblox-specific behavior before creating the instance.
export function createElement(
type: string | Function,
config?: Record<string, unknown>,
...children: unknown[]
): unknown;This wrapper does three important things for everyday UI work:
- converts text-as-children into Roblox text elements
- translates event props such as
onClickintoEvent={{ Activated: fn }} - parses
classNameinto actual Roblox property values
Example:
const button = React.createElement(
"textbutton",
{
onClick: () => print("clicked"),
className: "rounded-lg bg-blue-500 px-4 py-2 text-white",
},
"Save",
);Under the hood, that is roughly equivalent to:
const button = React.createElement(
"TextButton",
{
Event: {
Activated: () => print("clicked"),
},
BackgroundColor3: Color3.fromRGB(...),
Text: "Save",
},
);The point is that you do not need to hand-write this conversion logic every time. The wrapper handles it for you.
@nrbx/react supports the normal React-style component forms.
import React from "@nrbx/react";
type Props = {
title: string;
onClick?: () => void;
};
function MyComp(props: Props): React.ReactElement {
return <textbutton Text={props.title} onClick={props.onClick} />;
}import React from "@nrbx/react";
const MyComp: React.FC<Props> = (props) => {
return <textbutton Text={props.title} onClick={props.onClick} />;
};A lot of projects use this form for small UI pieces.
import React from "@nrbx/react";
type Props = { title: string };
type State = { count: number };
class MyComp extends React.Component<Props, State> {
state: State = { count: 0 };
render() {
return (
<frame>
<textlabel Text={this.props.title} />
<textbutton
Text={`Count: ${this.state.count}`}
onClick={() => this.setState({ count: this.state.count + 1 })}
/>
</frame>
);
}
}Children are available through props.children in function components and this.props.children in class components.
type PanelProps = {
title: string;
children?: React.ReactNode;
};
function Panel({ title, children }: PanelProps) {
return (
<frame>
<textlabel Text={title} />
{children}
</frame>
);
}There are a few rules worth keeping in mind when writing JSX in @nrbx/react.
<frame></frame>
<frame />Both are valid. Use the self-closing form for empty elements.
A component should return one root element, or a Fragment if you need multiple siblings.
function Example() {
return (
<frame>
<textlabel Text="One" />
<textlabel Text="Two" />
</frame>
);
}If you need multiple top-level nodes, wrap them in a Fragment:
function Example() {
return (
<>
<textlabel Text="One" />
<textlabel Text="Two" />
</>
);
}function Greeting({ name, show }: { name: string; show: boolean }) {
return (
<textlabel
Text={"Hello " + name}
Visible={show}
/>
);
}function Banner({ show }: { show: boolean }) {
return <frame>{show && <textlabel Text="Visible" />}</frame>;
}
function Status({ show }: { show: boolean }) {
return (
<frame>
{show ? <A /> : <B />}
</frame>
);
}function ItemList({ items }: { items: Array<{ id: string; label: string }> }) {
return (
<frame>
{items.map((item) => (
<textlabel key={item.id} Text={item.label} />
))}
</frame>
);
}key is important when rendering repeated children. It helps the reconciler keep list items stable during updates.
Fragments let you return multiple sibling elements without adding an extra wrapper.
function Pair() {
return (
<>
<textlabel Text="A" />
<textlabel Text="B" />
</>
);
}function Pair() {
return (
<React.Fragment>
<textlabel Text="A" />
<textlabel Text="B" />
</React.Fragment>
);
}function RowList({ rows }: { rows: Array<{ id: string; label: string }> }) {
return (
<>
{rows.map((row) => (
<React.Fragment key={row.id}>
<textlabel Text={row.label} />
</React.Fragment>
))}
</>
);
}Keyed Fragments are especially useful when you need to preserve identity while still returning multiple nodes in a mapped list.
The JSX runtime uses the tag name to decide what to render.
<frame />
<textlabel />
<textbutton />These are resolved as Roblox instance classes, usually through Instance.new(...) semantics.
<MyComponent />
<ProfileCard />Uppercase names are treated as user-defined component functions or classes. The runtime calls the component and passes the props object.
<div />
<span />
<button />
<h1 />These map through the HTML element resolver to the closest Roblox equivalent (Frame, TextLabel, TextButton, etc.). This makes the API familiar for developers coming from React or HTML.
@nrbx/react intentionally supports both native Roblox-style and HTML-like tags so you can write what feels natural for the component.
Props are just a plain object passed into your component. You can use them in the same ways you expect from React.
type CardProps = {
title: string;
onClick?: () => void;
};
function Card({ title, onClick }: CardProps) {
return <textbutton Text={title} onClick={onClick} />;
}type BadgeProps = { size?: "sm" | "md" | "lg" };
function Badge({ size = "md" }: BadgeProps) {
return <textlabel Text={size} />;
}const baseProps = {
title: "Save",
onClick: () => print("Saved"),
};
function Example() {
return <MyButton {...baseProps} extra="value" />;
}function Container({ children }: { children?: React.ReactNode }) {
return <frame>{children}</frame>;
}items.map((item) => <row key={item.id} item={item} />);key is used by the reconciler, not as a regular DOM-like prop. It is mainly for list stability and minimal re-creation of UI elements.
Refs are forwarded with React.forwardRef.
const MyInput = React.forwardRef<TextBox, { placeholder?: string }>(
(props, ref) => <textbox ref={ref} PlaceholderText={props.placeholder} />,
);This pattern is useful for components that wrap a Roblox instance and want to expose the instance reference to the parent.
Use it for input components, custom controls, and any component where the parent needs direct access to the underlying instance.
A higher-order component (HOC) takes a component and returns a new component with additional behavior.
function withLogger<P>(Component: React.ComponentType<P>) {
return (props: P) => {
print("Rendering", Component.displayName);
return <Component {...props} />;
};
}This is a common pattern for:
- logging and analytics
- injecting props or feature flags
- wrapping a component with behavior or tracking
Render props let a component control the rendering logic while the parent provides a render function.
function MouseTracker({ render }: { render: (pos: Vector2) => React.ReactElement }) {
const [pos, setPos] = React.useState(new Vector2(0, 0));
// ...track mouse
return render(pos);
}Usage:
function Example() {
return (
<MouseTracker
render={(pos) => (
<textlabel
Text={`Mouse: ${pos.X}, ${pos.Y}`}
Position={new UDim2(0, 0, 0, 0)}
/>
)}
/>
);
}This pattern is useful when the child wants to decide how the result should be displayed while the parent owns the state or lifecycle.
Here is the short version to keep in mind:
function Example() {
return (
<frame className="flex flex-col gap-2 p-4">
<textlabel Text="Hello" />
<textbutton onClick={() => print("Clicked")} className="bg-blue-500">
Press me
</textbutton>
</frame>
);
}Use this as your mental model:
- lowercase tag = Roblox instance
- uppercase tag = component call
-
{}= expression evaluation -
key= stable identity for lists - fragments = multi-root UI without wrapper elements
-
classNameandonClickare handled by thecreateElementwrapper
If you can write React components in a browser, the same structure works in Roblox with @nrbx/react — you are just targeting Roblox Instances instead of the DOM.