Scope and status answers “is text done?” at the area
level. This page answers “does AutomaticCanvasSize work?” at the property level.
Everything below describes loom 0.12.0 — the JSX intrinsics in @loom-dev/react, the DOM mapping
in @loom-dev/renderer, and the Rust layout engine in crates/loom-layout.
How to read this
| Mark | Meaning |
|---|---|
| Supported | Read by the layout engine or painted by the renderer. Behaves like Roblox, within the fidelity limits below. |
| Partial | Implemented, with a stated gap. Correct in the common case, wrong at the edge. |
| Accepted | Typed and settable, and nothing reads it. No error, no effect. |
| Not typed | No JSX prop and no runtime handling. A compile error, and it would not render either. |
Accepted is the row that costs you time, so it is spelled out. Those props exist so real component code, which sets them for the Roblox build, runs under the preview without edits.
The engine takes the string wherever it takes the item — AutomaticSize="XY" is Enum.AutomaticSize.XY, and loom reads either everywhere.
It reports the engine’s own number rather than the item’s position in loom’s declaration list, so code comparing .Value against a hardcoded integer may need updating. Comparing items directly, or matching on .Name, is unaffected.
Instances
| Intrinsic | Status | Notes |
|---|---|---|
screengui surfacegui billboardgui | Supported | LayerCollectors: never background-painted, always click-through, z-ordered among themselves by DisplayOrder. |
frame | Supported | |
scrollingframe | Supported | Sinks pointer input unconditionally, like Roblox. |
canvasgroup | Supported | GroupTransparency becomes CSS opacity on the container div, which is the same “composite the subtree, then fade it” model. |
textlabel textbutton textbox | Supported | See Text. |
imagelabel imagebutton | Supported | The Image paints in a layer beneath the text, in every ScaleType. See Image properties. |
viewportframe videoframe | Partial | Typed and laid out as plain GuiObjects. No 3D scene, no video — they paint as backgrounds. |
uilistlayout uigridlayout uipadding | Supported | |
uipagelayout uitablelayout | Supported | Implemented in 0.7.0. See Layout and modifier properties. |
uicorner uistroke uigradient | Supported | Mapped to border-radius (per-corner), a box-shadow ring on the side BorderStrokePosition asks for, and a CSS linear-gradient. |
uishadow | Supported | A CSS drop shadow, layered under a UIStroke ring rather than replacing it. |
uisizeconstraint uiaspectratioconstraint | Supported | |
uiscale | Accepted | Typed, recognized as a modifier, and read by nothing. A scaled subtree renders at 1×. |
uiflexitem | Supported | FlexMode — Grow / Fill, or Custom with GrowRatio. |
| Everything else | Not typed | uitextsizeconstraint, folder, part, … — the intrinsic set is exactly what the renderer implements. |
GuiObject properties
Shared by every visual class.
| Property | Status | Notes |
|---|---|---|
Size Position AnchorPoint | Supported | Full UDim2 scale + offset resolution. |
AutomaticSize | Supported | X / Y / XY grow-to-content, with the object’s own Size as the floor and the room its parent leaves as the ceiling — see below. |
BackgroundColor3 BackgroundTransparency | Supported | |
Visible | Supported | Hidden via CSS; the node still occupies its computed rect, as in Roblox. |
ZIndex | Supported | ZIndexBehavior.Sibling semantics — the renderer’s native model. |
LayoutOrder | Supported | Honored by every layout under SortOrder.LayoutOrder — which is not the default. See SortOrder defaults to Name. |
Rotation | Supported | Degrees clockwise about the element center, as a CSS transform. Purely visual: the layout rect is unrotated, matching Roblox. |
ClipsDescendants | Supported | |
Active | Partial | Honored at runtime, not declared in the props type, so setting it works and your editor will redline it. It governs whether input is sunk, not whether the object hears it: a frame with an InputBegan handler is hit-testable either way, and a frame with no listeners stays click-through. |
Name | Supported | |
| (unwritten properties) | Supported | Reading a property nobody has written yields the Roblox default — Visible, ZIndex, BackgroundTransparency, Rotation, LayoutOrder, Active, ClipsDescendants, AnchorPoint, Position, Size — not undefined. |
Event Change ref | Supported | Event={{ Activated }}, Change={{ Text }}, and ref to the live LoomInstance. |
BorderSizePixel BorderColor3 SizeConstraint Transparency Interactable | Not typed |
ScreenGui properties
| Property | Status | Notes |
|---|---|---|
DisplayOrder | Supported | Becomes the layer’s CSS z-index (may be negative). |
Enabled | Supported | |
ZIndexBehavior | Accepted | Sibling is what the renderer already does; Global is not emulated. |
IgnoreGuiInset | Accepted | The runtime’s GetGuiInset() is zero, so there is no inset to ignore. |
ResetOnSpawn ScreenInsets | Accepted | Typed so real code runs unchanged; nothing reads them. |
Text properties
| Property | Status | Notes |
|---|---|---|
Text TextColor3 TextSize TextTransparency | Supported | A newline in Text breaks the line and a run of spaces stays a run of spaces, as in Roblox — since 0.9.3, when the paint stopped folding both away through HTML’s defaults. |
TextXAlignment TextYAlignment | Supported | |
TextWrapped | Supported | white-space: normal vs nowrap, and applied during measurement — see below. The deprecated TextWrap alias reads as the same property and is declared on the props type as of 0.6.3. |
LineHeight | Supported | The multiplier, clamped to the 1…3 Studio allows and spent between lines: n lines measure TextSize + (n - 1) * TextSize * LineHeight, so a one-line label is exactly TextSize tall however high its LineHeight. The browser’s taller line boxes are handled by the clip rect rather than by cropping the glyphs — see below. |
FontFace | Supported | new Font(family, weight, style) resolved to a CSS family/weight/slant, including for text measurement. Preferred over the legacy Font enum when both are set. |
Font | Supported | The legacy enum — all 53 items in the engine’s own order as of 0.9.0 — mapped to a family by name prefix, with the weight read off the name suffix. 28 families load a real face; the eight the engine licenses privately (Gotham, BuilderSans, Bodoni, …) resolve to a stack and warn. See Fonts and text metrics. |
FontSize | Supported | The legacy Enum.FontSize — the pixel size is read out of the enum name, so Size24 paints and measures at 24px. TextSize wins when both are set, as in Roblox. |
RichText | Supported | <b>, <i>, <u>, <s>, <br/>, <font> (colour, size, face, family, weight, transparency), <uppercase> / <smallcaps> and the character entities. With the flag off — or on a tag the engine would not recognise either — the markup stays literal, as in Roblox. Measurement uses each run’s own font. |
TextScaled | Accepted | Never applied. Text renders at TextSize whether or not it fits its box. |
TextTruncate MaxVisibleGraphemes TextStrokeColor3 TextStrokeTransparency | Not typed | No ellipsis, no glyph outline. |
AutomaticSize on text
Auto-sizing text is measured browser-side. Font metrics do not exist inside the WASM engine. The
adapter runs a canvas measureText() with the font the renderer paints, and hands the result over
as TextBounds. Which typeface that is, and why it is worth pinning, is
Fonts and text metrics.
TextWrapped is honoured, and runs are laid into lines at word boundaries. One function decides
every wrap. Measurement asks it how many lines a label needs, and the text layer asks where the
breaks go. The advances behind it are the engine’s.
The wrap width is the nearest ancestor with a width of its own, less every UIPadding in
between — not the immediate parent. A parent that is itself AutomaticSize was sized by the
label, so wrapping against it never wraps.
The re-wrap settles inside the paint that caused it. The first measurement after a container narrows is against the old width. The adapter re-lays-out until the two agree, up to four passes before deferring to the next frame. It patches the DOM once.
A RichText string is measured run by run in the font its tags ask for, so a bold run does not
clip. An empty TextBox is measured against its PlaceholderText, or an AutomaticSize.Y input
would collapse to zero height.
Both adapters do this, so the same scene lays out identically through vide and react.
The wrap width also carries the UIPadding insets in between, including scale ones. A scale
inset resolves against the node’s own width where its X axis is real, and against zero where the
axis is automatic. Otherwise the width would set the padding, which would set the width.
TextSize is a face height, not a font size
TextSize means different things to the two renderers. Roblox fits the whole face into it, ascender
to descender. CSS font-size sets the em square, and a face’s ascent plus descent runs well past
1em.
Loom divides by the face box rather than painting font-size: TextSize straight through, which
would draw every glyph too big. LineHeight sets the line box in pixels off TextSize. The ratio
is calibrated per family — see
TextSize is not a font size.
The clip rect carries the overhang the browser’s taller line boxes need, and padding hands the content box its original height back. Otherwise a paragraph’s last line loses its descenders. Nothing about the layout moves, and a label still clips its text at its left and right edges.
AutomaticSize is bounded by the parent
Roblox bounds automatic growth: an AutomaticSize object grows up to the maximum its parent allows,
and a TextWrapped label grows to that maximum and only then wraps.
Every auto-sized node carries the ceiling its parent leaves, inherited through the padding in
between. Size remains the floor even past it, and a wrapping UIListLayout wraps against the same
ceiling. Content that still does not fit overflows, as it does in the engine.
A ScrollingFrame is the exception. Its children get no ceiling on an axis carrying
AutomaticCanvasSize or a CanvasSize of 0. A CanvasSize giving the axis a real extent is still
a ceiling.
Image properties
Shared by imagelabel and imagebutton. The image paints in a layer beneath the text, as a
background-painted element rather than an <img>. A sprite window and a 9-slice both place a
region of the source.
| Property | Status | Notes |
|---|---|---|
Image | Supported | http(s):, data: and blob: URLs load directly. rbxassetid:// is resolved by a dev-server route, and baked into a static build — see the callout below. |
ImageTransparency | Supported | |
ImageColor3 | Supported | A per-channel multiply through an feColorMatrix, matching the engine — so a full-colour image tints as correctly as a monochrome icon. The default white costs no filter at all. |
ScaleType | Supported | All five as of 0.7.0: Stretch, Fit, Crop, Slice and Tile. |
SliceCenter SliceScale | Supported | The 9-slice centre in the source image’s own pixels, painted as a CSS border-image: corners keep their size, edges stretch along one axis, the middle along both. SliceScale scales the painted border without touching the source. A SliceCenter that leaves no centre (the default Rect.new(0, 0, 0, 0) included) stretches instead. |
TileSize | Supported | One tile’s size for ScaleType.Tile, a UDim2 against the node — so UDim2.fromScale(0.5, 0.5) is four tiles. Defaults to {1,0},{1,0}, one tile filling the node. |
ImageRectOffset ImageRectSize | Supported | The sprite window, in image pixels. A zero ImageRectSize means the whole image, as in Roblox. The window is clipped to its own destination rect, so a sheet’s neighbouring sprites never leak under Fit. |
ResampleMode | Supported | Pixelated becomes CSS image-rendering: pixelated — pixel art scales up crisp instead of blurred. |
Two combinations are not reproduced, and say so rather than painting something wrong:
ScaleType.Tilewith anImageRectwindow. CSS backgrounds cannot repeat a region, so the whole image tiles and loom warns once, naming the node.- A
SliceCentermeasured against a window rather than against the whole image. The slice insets are read from the source’s natural size.
Roblox’s thumbnail API sends no CORS headers, so the browser cannot resolve an asset id on its own.
@loom-dev/preview installs a resolver for both cases:
- Under a dev server (
loom preview, the embedded server, Next dev) the id is resolved server-side and answered as a redirect from<base>__loom/asset/<id>. - In a static build (
loom build,vite build) there is no server later. The ids are resolved at build time and the images downloaded into the output. A__loom/assets.jsonmanifest points the page at them. See Static builds and embedding.
The build finds those ids two ways. It reads the emitted output for rbxassetid://<digits>, and it mounts each gallery target in node and reads the live tree, catching an id composed at runtime. An image the first render never reaches is still missed — install your own resolver with setImageResolver for those.
TextBox properties
| Property | Status | Notes |
|---|---|---|
PlaceholderText | Supported | Becomes the DOM input’s placeholder. |
ClearTextOnFocus TextEditable MultiLine | Supported | |
PlaceholderColor3 | Accepted | The placeholder is not restyled — you get the browser default. |
ScrollingFrame properties
| Property | Status | Notes |
|---|---|---|
CanvasSize | Supported | Children lay out against the canvas, not the window. |
CanvasPosition | Supported | Applied as the DOM scroll offset by the renderer, not by the layout engine. |
AutomaticCanvasSize | Supported | X / Y / XY. The affected axis grows to the union bounding box of the frame’s direct children, then max()ed with the resolved CanvasSize. |
ScrollingDirection ScrollingEnabled | Supported | Decide which axes can scroll, and therefore which bars can appear. |
ScrollBarThickness | Supported | The bar’s width, default 12. A thickness of 0 shows no bar, as in Roblox. |
ScrollBarImageColor3 | Supported | Tints the thumb. Untinted, loom draws the engine’s grey rather than the property’s own white default — the engine tints grey sprites loom does not fetch, and a white bar would be invisible on most surfaces. |
ScrollBarImageTransparency | Accepted | The thumb is drawn opaque. |
VerticalScrollBarInset HorizontalScrollBarInset | Accepted | The bar is always painted over the canvas — Roblox’s ScrollBarInset.None, which is also the engine’s default. AbsoluteWindowSize reserves nothing for it, so a layout that budgets for an inset bar comes out that many pixels wider here than in Studio. |
A scrolling list in Roblox is an AutomaticSize column inside an AutomaticCanvasSize frame. A ScrollingFrame therefore leaves its children no ceiling on an axis whose canvas is free to grow. Otherwise the column comes out exactly the window’s height and nothing scrolls. The bar is drawn from the same window/canvas maths the adapters feed back, so it cannot disagree with the numbers a component sized itself against.
AutomaticSize on a ScrollingFrame that also sets CanvasSize is explicitly deferred in the
layout engine. The combination will not error. It will not size the way Studio does either.
Layout and modifier properties
| Instance | Supported | Accepted / partial |
|---|---|---|
uilistlayout | FillDirection, HorizontalAlignment, VerticalAlignment, SortOrder, Padding, HorizontalFlex, VerticalFlex, Wraps | — |
uigridlayout | CellSize, CellPadding, FillDirection, FillDirectionMaxCells, HorizontalAlignment, VerticalAlignment, SortOrder | StartCorner — only TopLeft is implemented; the other three corners fall back to it. |
uitablelayout | MajorAxis, Padding, FillEmptySpaceColumns, FillEmptySpaceRows, HorizontalAlignment, VerticalAlignment, SortOrder | See UITableLayout. |
uipagelayout | FillDirection, Padding, Circular, HorizontalAlignment, VerticalAlignment, SortOrder | Animated, TweenTime, EasingStyle, EasingDirection and the gamepad/touch/scroll input flags are accepted: a preview shows the settled layout, so page changes are instant. See UIPageLayout. |
uiflexitem | FlexMode (Grow / Fill / Custom), GrowRatio | — |
uipadding | PaddingTop, PaddingBottom, PaddingLeft, PaddingRight | A scale inset resolves against the node’s own size on a real axis, and against zero on an AutomaticSize axis — where it would be circular. Text measurement asks the same question as of 0.8.1. |
uisizeconstraint | MinSize, MaxSize | — |
uiaspectratioconstraint | AspectRatio, DominantAxis | AspectType — ScaleWithParentSize is deferred. |
uicorner | CornerRadius, TopLeftRadius, TopRightRadius, BottomLeftRadius, BottomRightRadius | Each per-corner radius overrides CornerRadius for its own corner — that is how a card rounds only its top while its footer rounds only its bottom. The UIStroke ring and the UIShadow are box-shadows, so they follow the radius for free. |
uistroke | Color, Thickness, Transparency, Enabled, BorderStrokePosition | Outer (the default) spreads outward, Inner insets so the stroke eats into the object instead of inflating it, Center straddles the edge. Enabled = false or a fully transparent stroke paints nothing. ApplyStrokeMode — the ring is always drawn border-style, so Contextual on a text class does not outline glyphs. |
uigradient | Color, Rotation, Enabled | Offset is ignored. The gradient overlays the background rather than multiplying it, and Transparency (a NumberSequence) is not typed at all. |
uishadow | Color, Offset, BlurRadius, Spread, Transparency, Enabled, ZIndex | Drawn as a CSS drop shadow under the UIStroke ring. |
uiscale | — | Scale is ignored entirely. |
UIListLayout and UIGridLayout report AbsoluteContentSize back after layout, gated on real
change. A control that sizes itself from Change={{ AbsoluteContentSize }}, such as a dropdown,
gets a real number instead of collapsing to zero height.
SortOrder defaults to Name
Every UIGridStyleLayout defaults SortOrder to Name, the engine’s own default — so a list
whose children carry distinct Names flows alphabetically, not in source order. The sort is
stable, so a tree that never sets Name is unaffected.
Loom defaulted to LayoutOrder until 0.7.0. If a list reordered itself when you upgraded, it was
already ordering that way in Studio. Set SortOrder={Enum.SortOrder.LayoutOrder} to get source
order back.
UITableLayout
The layout’s siblings are the table’s lines: rows, or columns under MajorAxis.ColumnMajor. Each
line’s own children are the cells:
<frame Size={UDim2.new(1, 0, 0, 120)}> <uitablelayout Padding={UDim2.new(0, 8, 0, 4)} FillEmptySpaceColumns={true} /> <frame Name="Row1"> <textlabel Text="Region" Size={UDim2.fromOffset(120, 26)} /> <textlabel Text="Players" Size={UDim2.fromOffset(100, 26)} /> </frame></frame>A column is as wide as its widest cell, and a row as tall as its tallest. Both are measured against
the table’s content box, so a 0.25 scale cell is a quarter of the table, not of its row. A
line spans the whole table on its minor axis. Its own Size is ignored, as is any UIPadding on
it, which would otherwise slide its cells off the columns they define.
FillEmptySpaceColumns and FillEmptySpaceRows scale the tracks proportionally so the table
spans its container. It works in both directions, so a table whose natural width overruns is
squeezed by the same rule. A hidden cell takes neither a track nor a gap.
UIPageLayout
Pages keep their own Size and sit one container-plus-Padding apart along FillDirection, so a
parent with ClipsDescendants shows exactly one. Which page that is, is state — changed by method
call as in Roblox, through a ref:
const pager = useRef<{ Next(): void; CurrentPageIndex: number }>();
<frame Size={UDim2.new(1, 0, 0, 90)} ClipsDescendants={true}> <uipagelayout ref={(instance) => { pager.current = instance as never; }} Padding={UDim.new(0, 16)} Circular={true} /> {/* …pages… */}</frame>;JumpToIndex(i), JumpTo(page), Next() and Previous() all work and fire PageLeave →
PageEnter → Stopped. Circular decides whether Next and Previous wrap at the ends.
Roblox’s CurrentPage is a GuiObject reference, and a Scene IR property value is a datatype rather than a node, so it cannot cross the wasm boundary. The layout engine reads CurrentPageIndex, a 0-based int the runtime keeps in step. CurrentPage still reads back as the instance.
That only matters if you drive a pager from something other than the @rbxts/react adapter: set
CurrentPageIndex on the node yourself.
Instance API
Every mounted object is a real instance with Roblox’s reflection surface on it, not a React element
with a Roblox-shaped name. So a ref behaves the way a ref does in a place.
| Group | What is there |
|---|---|
| Identity | ClassName, Name, Parent, IsA, GetFullName |
| Tree | GetChildren, GetDescendants, FindFirstChild (with recursive), FindFirstChildOfClass, FindFirstAncestor, FindFirstAncestorOfClass, FindFirstAncestorWhichIsA, IsDescendantOf |
| Lifetime | Destroy, ClearAllChildren, Clone with Archivable — (0.12.0) — and the Destroying / ChildAdded / ChildRemoved / AncestryChanged signals |
| Change notification | GetPropertyChangedSignal(name) and Changed, which fires with the property name |
| Geometry | AbsolutePosition / AbsoluteSize, written back after each layout pass and signalled only when they actually move |
| Attributes | GetAttribute, SetAttribute, GetAttributes, GetAttributeChangedSignal, AttributeChanged — (0.11.0) |
TextBox | CaptureFocus, ReleaseFocus, IsFocused |
BindableEvent | Event and Fire, class-scoped so no other class answers for those names |
WaitForChild does not yield. The runtime is synchronous, so it returns the child if it is
already there and otherwise warns rather than blocking the frame. Code that waits on a child built
later in the same render needs restructuring, not patience.
A property read before it is written answers with a typed default, the way Roblox reflection
does rather than with undefined. That is 0 for a number, "" for a string, and the class’s own
default for a datatype. A UI that reads frame.BackgroundTransparency before setting it used to get
undefined and propagate a NaN through whatever it computed.
Attributes
The second namespace on every instance, and the one an app owns outright, landed in 0.11.0. It
behaves as it does in a place. An attribute is not readable as a property, does not fire Changed,
and reaches neither the renderer nor the Scene IR. Writing one paints nothing and schedules no
flush.
const player = Players.LocalPlayer;
player.GetAttributeChangedSignal("ColorScheme").Connect(() => { print(player.GetAttribute("ColorScheme"));});
player.SetAttribute("ColorScheme", "dark"); // firesplayer.SetAttribute("ColorScheme", "dark"); // unchanged — silent, as in Robloxplayer.SetAttribute("ColorScheme", undefined); // removes it, and still firesGetAttributes() hands back a snapshot rather than the live store. Names are validated the way the
engine validates them: up to 100 alphanumerics and underscores, with the RBX prefix reserved. A
name a real place would refuse throws here rather than working in the preview and failing in Studio.
Input
Pointer and keyboard input route through the real runtime, not DOM shims. Activated,
MouseButton1Click, InputBegan / InputChanged / InputEnded, UserInputService, focus and
TextBox editing all behave as in Roblox, input-sinking included.
0.12.0 closed the gap where the rest of the mouse events were connectable but never
dispatched, which is worse than absent. A handler attached to one silently did nothing.
MouseButton1Down, MouseButton1Up, MouseButton2Click and MouseMoved fire now. Keyboard input
reaches the runtime too, so UserInputService.IsKeyDown and a KeyCode comparison answer what the
user actually pressed. Enum.KeyCode gained its digits, modifiers and gamepad buttons in the same
release, which is what a keyboard-driven UI was missing to match against. Only TextButton,
ImageButton, TextBox, ScrollingFrame and anything with Active = true sink pointer input — a
transparent Frame is click-through, exactly as in Roblox.
Active governs sinking, not hearing. A plain Frame with an InputBegan handler, such as a
slider handle, is hit-testable whether or not it is Active. A frame with no listeners stays
click-through, so a transparent positioning layer does not swallow clicks.
What this list is not
It is a description of loom 0.12.0, not a compatibility promise. The project is pre-1.0 and rows
move — an Accepted here is a gap someone can close without a breaking change. When a preview and
Studio disagree and the property is not on this page, treat Studio as correct and
file it.