# Lattice UI > Headless Roblox UI primitives for rbxts/react interfaces. - Docs: https://docs.astra-void.xyz/lattice-ui/ - Source: https://github.com/astra-void/lattice-ui # Lattice UI > Headless Roblox UI primitives for rbxts/react. Source: https://docs.astra-void.xyz/lattice-ui/ Lattice UI provides behavior primitives for Roblox interfaces built with rbxts/react. Use it for dialogs, popovers, menus, selects, and tabs where focus, layering, state, and dismissal need to stay predictable. --- # Installation > Add a Lattice UI primitive to an rbxts/react app, by hand or with the lattice CLI. Source: https://docs.astra-void.xyz/lattice-ui/getting-started/installation/ Lattice UI is a set of headless primitives for `rbxts/react`. Each primitive ships as its own `@lattice-ui/react-` package, owns the tricky interaction work — open state, focus, layering, motion — and leaves the visuals to you. You install only the packages a screen actually uses. This page gets one primitive into your project. The rest of getting started uses Dialog as the worked example, so installing `@lattice-ui/react-dialog` here is a good place to start. > **The react- prefix** > > Since **0.6.1** every package is namespaced by framework layer: `@lattice-ui/` became `@lattice-ui/react-`, and the old `@lattice-ui/core` is now `@lattice-ui/react-runtime`. This is a rename only — no exports or APIs changed. If you are upgrading from 0.6.0 or earlier, add the prefix to every import, and run `npx lattice-ui init` to rewrite the old names in your `package.json` — leaving both names listed makes the old and new copies resolve side by side, which npm rejects. ## Prerequisites Lattice UI runs on top of `rbxts/react`, so your project should already have the React runtime available. ```ts title="React runtime" import React from "@rbxts/react"; import ReactRoblox from "@rbxts/react-roblox"; ``` `@rbxts/react` and `@rbxts/react-roblox` are **peer dependencies** of every primitive (`^17.3.7-ts.1`). Install them yourself if they are not already in your project — the primitive packages do not bundle them. ## Install a package ```bash pnpm add @lattice-ui/react-dialog ``` Install one primitive at a time. Each package pulls in the shared foundations it needs — every primitive depends on `@lattice-ui/react-runtime`, and Dialog additionally brings `@lattice-ui/react-focus`, `@lattice-ui/react-layer`, and `@lattice-ui/react-motion` — so you do not add those by hand. ```ts title="Import the primitive" import { Dialog } from "@lattice-ui/react-dialog"; ``` ## Use the lattice CLI If you would rather not manage packages by hand, the `lattice` CLI installs primitives for you, adds the `@rbxts/react` peers alongside them, and resolves your package manager automatically from the project lockfile. It needs **Node 20+** and runs straight from npm — no install required. ```bash title="Add a primitive" npx lattice-ui add dialog ``` Note the argument is the registry name (`dialog`), not the npm package name — the CLI maps it to `@lattice-ui/react-dialog` for you. You can add several primitives at once — space- or comma-separated — or pull in a curated preset. `overlay` bundles popover, tooltip, dialog, and toast, while `form` bundles checkbox, radio-group, switch, text-field, and textarea. The full command and preset reference lives in the [CLI reference](https://docs.astra-void.xyz/lattice-ui/reference/cli.md). ```bash title="Add multiple primitives and a preset" npx lattice-ui add dialog toast --preset overlay ``` Run `add` with no names and no `--preset` and it prompts you to pick presets and components interactively. Add `--dry-run` to print the exact install command without touching anything. The CLI also scaffolds and maintains projects: `lattice create` starts a new `rbxts` project, `lattice init` wires the toolchain into an existing one, and `lattice remove` / `lattice upgrade` keep your installed primitives in sync. Run `lattice doctor` if you want it to check your setup. > **Pick a package manager** > > The manual install works with any package manager. The CLI supports pnpm, npm, and yarn — pass `--pm ` to any command to override the manager it detects from your lockfile. ## Verify it works Render the minimum useful surface to confirm the import resolves and the primitive mounts: ```tsx title="smoke-test.tsx" import React from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function SmokeTest() { return ( ); } ``` ## Next step Build a real, controlled surface in [Your first dialog](https://docs.astra-void.xyz/lattice-ui/getting-started/first-dialog.md). --- # Your first dialog > Build a controlled, modal dialog step by step with the Dialog primitive. Source: https://docs.astra-void.xyz/lattice-ui/getting-started/first-dialog/ Dialog is a good first primitive because it shows the whole Lattice UI model in one surface: a `Root` that owns state, parts that read from it, and an app-owned frame for the visuals. By the end of this walkthrough you will have a working confirmation dialog that opens from a button, traps focus, and closes predictably. This page is a guided build, not a reference. For the full prop list and behavior details, see the [Dialog component page](https://docs.astra-void.xyz/lattice-ui/components/dialog.md). > **Before you start** > > This assumes `@lattice-ui/react-dialog` is installed. If it is not, follow [Installation](https://docs.astra-void.xyz/lattice-ui/getting-started/installation.md) first. ## Step 1 — Import and sketch the anatomy `Dialog` is a compound component: a `Root` and a set of parts hanging off it. Start from the import and the shape you are going to fill in. ```tsx title="ConfirmDialog.tsx" import React from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function ConfirmDialog() { return ( ); } ``` `Root`, `Portal`, and `Content` are the minimum useful surface; `Trigger`, `Overlay`, and `Close` are there to drive and dress it. This sketch compiles, but it renders nothing you can see — every part is unstyled until you say otherwise. That is the anatomy, not a working dialog; the next steps fill it in. ## Step 2 — Add a trigger `Dialog.Trigger` is the button that opens the surface. Use `asChild` so your own `textbutton` becomes the trigger instead of rendering a default one — the trigger behavior merges onto the element you provide. ```tsx title="The trigger" ``` The trigger also registers itself as the focus-restore target, so when the dialog closes, selection returns here automatically. ## Step 3 — Build the surface `Dialog.Portal` renders the surface into a `ScreenGui` above your game UI, `Dialog.Overlay` is the backdrop, and `Dialog.Content` is the focus-trapped, dismissable panel. The visuals are entirely yours — Lattice only owns the behavior. That is literal: an overlay you do not style is fully transparent. It still covers the screen and still swallows clicks (which is how outside-press dismissal works), but it draws nothing. Give it a color to get a visible dim. Because props forward onto the instance each part renders, you can style the overlay directly rather than nesting a frame inside it: ```tsx title="Overlay and content" ``` `Dialog.Content` traps focus while open and restores it on close without any configuration. Motion is opt-in: leave `transition` off and the dialog still opens and closes correctly, just instantly. `DIALOG_RISE` is the transition itself. `Dialog.Content` renders a full-screen `Frame`, so what it can animate is where the surface sits — an 8-pixel rise on the way in, the same offset on the way out: ```tsx title="The transition" import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion"; const DIALOG_RISE: PresenceMotionConfig = { target: motionTargets.offsetWrapper("dialog rise"), initial: { Position: UDim2.fromOffset(0, 8) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.12, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 8) }, intent: { duration: 0.096, tempo: "swift", tone: "calm" }, }, }; ``` Keep the transition to `Position`. The host spans the whole layer, so fading *its* `BackgroundTransparency` fills the screen with a rectangle rather than fading the panel — see [Fading a dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md#motion-and-presence) for how to fade the surface itself. The `target` declares which properties motion is allowed to own, which is what keeps it from fighting your layout. > **Presence timing is separate from motion** > > Even with no `transition`, content stays mounted until its exit finishes, so an exit animation can never be cut off mid-way. The transition decides what animates; presence decides when the tree unmounts. ## Step 4 — Add a close action Put a `Dialog.Close` inside the content for an explicit close. Like the trigger, it accepts `asChild` so your own button drives it. ```tsx title="Close from inside" ``` Because the dialog is **modal** by default, an outside press also dismisses it — so the close button is a convenience, not the only way out. ## Step 5 — Control the open state So far the dialog runs uncontrolled: the trigger and close button drive it through shared context. That is enough for most cases. When something outside the dialog needs to open or close it, lift the state with `open` and `onOpenChange`. ```tsx title="ConfirmDialog.tsx" import React, { useState } from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion"; const DIALOG_RISE: PresenceMotionConfig = { target: motionTargets.offsetWrapper("dialog rise"), initial: { Position: UDim2.fromOffset(0, 8) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.12, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 8) }, intent: { duration: 0.096, tempo: "swift", tone: "calm" }, }, }; export function ConfirmDialog() { const [open, setOpen] = useState(false); return ( ); } ``` Controlled and uncontrolled usage behave identically — the trigger, the close button, and an outside press all flow through the same state, so `onOpenChange` fires no matter how the dialog opens or closes. > **When to control state** > > Reach for `open`/`onOpenChange` only when an outside actor needs to drive the dialog — a server response, a hotkey, or a parent screen. Otherwise prefer `defaultOpen` and let Dialog own it. ## Recap - `Dialog.Root` owns the open state; every part reads it through context. - Primitives ship unstyled: pass appearance props straight to a part, or use `asChild` when you need a different instance class. - `asChild` lets your own `textbutton` become the trigger and close button. - `Dialog.Content` traps and restores focus for free; motion is opt-in through `transition`. - A modal dialog dismisses on an outside press, not just the close button. ## Next step See how this same shape repeats across every primitive in the [Composition model](https://docs.astra-void.xyz/lattice-ui/getting-started/composition-model.md), or read the full [Dialog reference](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) for every prop. --- # Composition model > The shared compound-component, asChild, and controllable-state model every Lattice UI primitive follows. Source: https://docs.astra-void.xyz/lattice-ui/getting-started/composition-model/ Every Lattice UI primitive is built from the same three ideas: a compound component with a `Root` and named parts, an `asChild` escape hatch for merging behavior onto your own elements, and controllable state that works either controlled or uncontrolled. Learn them once with Dialog and they carry over to Popover, Menu, Tabs, Combobox, and the rest unchanged. This page explains the model itself. The component pages assume you already know it. ## Compound components A primitive is a namespace object. `Root` owns the state and shares it through React context; the parts read from that context instead of holding their own copies. You compose them like nested elements. ```tsx title="The compound shape" import { Dialog } from "@lattice-ui/react-dialog"; ; ``` The same shape repeats everywhere — only the part names change to fit the primitive: ```tsx title="Same model, different primitive" import { Tabs } from "@lattice-ui/react-tabs"; ; ``` Because the parts share one context, a `Tabs.Trigger` knows which panel is active and a `Dialog.Close` knows how to close its `Root` — without you threading any props between them. ## Controllable state State-bearing roots are built on the `useControllableState` helper from `@lattice-ui/react-runtime`. The result is a consistent contract: pass a controlled value, or pass a default and let the primitive own it. Overlay primitives expose **open** state: - `open` — the controlled value - `defaultOpen` — the initial value when uncontrolled - `onOpenChange` — called on every change, in both modes ```tsx title="Controlled vs uncontrolled open state" // Uncontrolled — Dialog owns the state. {/* ... */} // Controlled — your screen owns the state. const [open, setOpen] = useState(false); {/* ... */} ``` Value-bearing primitives follow the identical pattern with **value** instead of open — for example `Tabs.Root` accepts `value`, `defaultValue`, and `onValueChange`: ```tsx title="The same contract for a selected value" {/* ... */} const [tab, setTab] = useState("general"); {/* ... */} ``` The mechanics are the same in both cases: when you pass the controlled prop, the primitive defers to you and only reports changes through the `on*Change` callback; when you omit it, the primitive holds the state internally and still reports changes. > **Keep one owner** > > Pass either the controlled prop or the default, not both for the same state. `onOpenChange` / `onValueChange` fire either way, so you never lose the change event by going uncontrolled. ## asChild Behavior-carrying parts render their own host element (a `textbutton`, for triggers). `asChild` tells the part to merge its behavior onto the single child you provide instead, using the `Slot` utility from `@lattice-ui/react-runtime`. Use it when you need a different instance class than the part renders, or when your app already owns the right element. Styling a part is *not* a reason to reach for `asChild`: unknown props forward onto the instance the part renders, so `` works on its own. ```tsx title="Render the part as your own element" ``` `Slot` does a real merge, not a replacement. It composes refs across the part and your element, and chains event handlers (`Event` and `Change`) so both the part's handler and yours run. That is why the trigger above still opens the dialog while keeping any `Event.Activated` you set on the `textbutton`. `asChild` needs the subtree to resolve to exactly one `GuiObject`; fragments are looked through and Roblox UI modifiers (`uicorner`, `uipadding`, and friends) may sit alongside it. The parts that support it — triggers, close buttons, overlays, and similar — say so in their reference tables; do not assume every part takes it. ## App-owned visuals The split is deliberate: the primitive owns interaction, your app owns presentation. Frames, sizing, color, typography, and layout live in your code. This is what makes the same primitive fit a settings panel, a store window, and a confirmation prompt without forking it. That split is literal. A primitive sets behavior plus the minimum needed to neutralize Roblox's own instance defaults — a bare `textbutton` would otherwise render an opaque grey box labelled "Button" — and nothing else. No colors, no fixed sizes, no font sizes, no placeholder text. What a primitive *does* keep is geometry it computes from state: progress fill ratios, slider thumb travel, popper-resolved position, scroll thumb size. ```tsx title="Behavior from the part, visuals from you" {/* your layout, your tokens */} ``` `Dialog.Content` still traps focus and handles dismissal — neither of which the frame inside it has to know about. It holds its content mounted until any exit transition finishes too, though the transition itself is opt-in: pass `transition` when you want one. ## Why it is the same everywhere Once you can read one primitive, you can read them all: find the `Root`, check whether it is open- or value-controlled, scan the parts, and drop `asChild` where you want your own element. The behavior contract stays stable across packages while your app keeps full control of how things look. ## Next step Apply the model end to end in [Your first dialog](https://docs.astra-void.xyz/lattice-ui/getting-started/first-dialog.md), then browse the [Dialog reference](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) to see the per-part props in context. --- # Package stability > How to read Lattice UI's 0.x lockedstep versions and which packages are stable-direction versus experimental. Source: https://docs.astra-void.xyz/lattice-ui/getting-started/package-stability/ import { LATTICE_MINOR, LATTICE_VERSION } from "@/lib/lattice-version"; Lattice UI is in the `0.x` phase. The whole workspace shares one **lockedstep** version, so the number bumps together across every package — but that single number is not a maturity guarantee. Some packages are on a clear path to `v1.0`; others are available today while their APIs are still settling. This page tells you which is which so you can pick packages with the right expectations. ## What lockedstep 0.x means Every package publishes at the same version. That keeps installs coherent — you never have to reconcile mismatched primitive versions — but it also means the version alone cannot tell you how stable any single package is. > **Read the tier, not just the number** > > Treat the version as a release coordinate and the stability tier below as the real maturity signal. A `0.x` release can contain both a hardened foundation and an intentionally narrow experimental package. ## Stability tiers ### Stable direction These packages represent the long-term direction of Lattice UI and are the main path toward `v1.0`. Build on them with confidence. - **Foundations:** `runtime`, `focus`, `layer`, `motion`, `style`, `system` - **Primary UI:** `accordion`, `avatar`, `checkbox`, `combobox`, `dialog`, `menu`, `popover`, `progress`, `radio-group`, `scroll-area`, `switch`, `tabs`, `text-field`, `textarea`, `toast`, `toggle-group`, `tooltip` ### Experimental and feature-limited These packages are usable, but treat them as evolving or intentionally limited in scope. Expect their APIs to move more than the stable-direction surface. - `popper` — experimental positioning foundation with placement-relative offsets and viewport collision handling - `context-menu` — pointer-driven only: it opens on a secondary click and highlights on hover, with no focus registration, ordered movement, or focus restore - `select` — currently single-value only - `slider` — currently single-thumb only > **Plan for change** > > If you depend on `popper`, `context-menu`, `select`, or `slider`, pin to a known-good version and expect to revisit the integration as their surfaces firm up. The single-value, single-thumb, and pointer-only limits are current scope, not the end state. ## What v1.0 means here The `v1.0` milestone targets the **main stable UI layer**, not every package in the workspace. The priority is a dependable foundation (`runtime`, `focus`, `layer`, `motion`, `style`, `system`) with predictable composition, state, focus, keyboard navigation, layering, portal, and motion behavior across the primary primitives — plus clearer semver expectations for that surface. Crucially, reaching `v1` for the main UI layer does **not** automatically stabilize the experimental or tooling packages. Some primary primitives may feel `v1`-ready in practice before the milestone lands, while feature-limited packages can stay in `0.x` for longer and only graduate when their APIs are actually ready. ## What is coming The current release is `v{LATTICE_VERSION}`. It finishes what `v0.7.0` started: `0.7` drew the line between behavior and appearance — every primitive ships unstyled, props forward onto the instance a part renders and are type-checked against it, and motion only runs when you pass a `transition` — and `v{LATTICE_MINOR}.0` takes the last appearance decision out of the primitives, rendering every motion host as a `Frame` rather than a `CanvasGroup` so no part flattens your content into a composited layer you did not ask for. Together they settle the question each primitive's API was answering inconsistently, and that is what the stable-direction surface carries into `v1.0`. See [Migration](https://docs.astra-void.xyz/lattice-ui/reference/migration.md) if you are coming from `0.6` or `0.7`. The `v0.6.x` line before them focused on hardening: more reliable layered and composite primitives, steadier motion and exit transitions, a real keyboard-navigation foundation rather than leaning on Roblox default selection, stronger focus restoration and trapping, and wider regression coverage. After `v1.0`, the feature-limited packages continue maturing independently and are promoted to stable versioning only once their APIs and behavior are ready. ## Next step Start from a stable-direction primitive: follow [Installation](https://docs.astra-void.xyz/lattice-ui/getting-started/installation.md) and then [Your first dialog](https://docs.astra-void.xyz/lattice-ui/getting-started/first-dialog.md). --- # Checkbox > A checked-state primitive with indeterminate support, controlled or uncontrolled state, and an optional presence-animated indicator. Source: https://docs.astra-void.xyz/lattice-ui/components/checkbox/ `@lattice-ui/react-checkbox` · Stable direction · import `Checkbox` · depends on `runtime`, `layer`, `motion` Checkbox is the primitive for a toggleable on/off control with an optional third `"indeterminate"` state. It owns the checked state, the toggle logic, and the reveal/exit motion of its indicator, so your component only has to render the box and what goes inside it. Reach for Checkbox for settings toggles, opt-ins, multi-select lists, and "select all" headers — anywhere you need a tri-state value (checked, unchecked, or indeterminate) with controlled or uncontrolled state and a built-in animated indicator. For a plain boolean flip with a sliding handle, [Switch](https://docs.astra-void.xyz/lattice-ui/components/switch.md) is usually the better fit. ## Preview The component running live — the same `@rbxts/react` tree Roblox renders, mounted in the browser. Click the boxes to toggle them. _Interactive preview._ ## Import ```ts import { Checkbox } from "@lattice-ui/react-checkbox"; ``` ## Anatomy Compose `Root` as the toggleable button and place an `Indicator` inside it to show the checked state. ```tsx title="Checkbox anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Checkbox.Root` | yes | Owns the checked state, handles toggling, and renders the activatable button. | | `Checkbox.Indicator` | no | Mounts while the box is checked or indeterminate. Animates if given a `transition`. | ## Examples ### Basic usage Uncontrolled state seeded with `defaultChecked`. The root renders an unstyled `textbutton` with no label of its own, so give it a size and colors — the state below is wired up correctly either way, but nothing is drawn until you say what it should look like. ```tsx title="BasicCheckbox.tsx" import { Checkbox } from "@lattice-ui/react-checkbox"; export function BasicCheckbox() { return ( print(`checkbox is now: ${checked}`)} > ); } ``` ### Controlled state Pass `checked` and `onCheckedChange` when something outside the checkbox needs to read or set the value — persisting a setting, syncing with a server, or resetting from elsewhere. ```tsx title="RememberMeCheckbox.tsx" import { useState } from "@rbxts/react"; import { Checkbox } from "@lattice-ui/react-checkbox"; export function RememberMeCheckbox() { const [checked, setChecked] = useState(false); return ( ); } ``` ### Custom box with asChild Use `asChild` to project the toggle behavior onto your own button, and give the indicator a glyph. The root no longer touches your box's color, so branch on the checked state yourself. If you want the indicator to animate in, pass a `transition` built with `createIndicatorRevealRecipe` at your indicator's actual size. ```tsx title="StyledCheckbox.tsx" import { useState } from "@rbxts/react"; import { Checkbox } from "@lattice-ui/react-checkbox"; import { createIndicatorRevealRecipe } from "@lattice-ui/react-motion"; const INDICATOR_REVEAL = createIndicatorRevealRecipe(UDim2.fromOffset(16, 16)); export function StyledCheckbox() { const [checked, setChecked] = useState(false); return ( ); } ``` ### Select all with indeterminate The classic tri-state pattern: a header checkbox derived from a set of row checkboxes. The header shows `"indeterminate"` while only some rows are checked; toggling it from that state resolves to `true` (checking everything). ```tsx title="PartyInviteList.tsx" import { useState } from "@rbxts/react"; import { Checkbox } from "@lattice-ui/react-checkbox"; const MEMBERS = ["Aria", "Bolt", "Cinder"]; export function PartyInviteList() { const [invited, setInvited] = useState>({}); const invitedCount = MEMBERS.filter((member) => invited[member] === true).size(); const allChecked: boolean | "indeterminate" = invitedCount === MEMBERS.size() ? true : invitedCount === 0 ? false : "indeterminate"; const setAll = (checked: boolean | "indeterminate") => { const nextInvited: Record = {}; for (const member of MEMBERS) { nextInvited[member] = checked === true; } setInvited(nextInvited); }; return ( {MEMBERS.map((member) => ( setInvited({ ...invited, [member]: checked === true })} asChild > ))} ); } ``` ### Gating an action A common game-UI pattern: a confirmation checkbox that must be checked before a destructive or costly action goes through, such as confirming a trade. ```tsx title="TradeConfirmation.tsx" import { useState } from "@rbxts/react"; import { Checkbox } from "@lattice-ui/react-checkbox"; export function TradeConfirmation(props: { onConfirm: () => void }) { const [accepted, setAccepted] = useState(false); return ( { if (accepted === true) { props.onConfirm(); } }, }} Size={UDim2.fromOffset(120, 32)} Text="Confirm trade" TextColor3={Color3.fromRGB(240, 244, 252)} /> ); } ``` ### Disabled state `disabled` blocks toggling entirely — activation is ignored and `setChecked` calls from context are dropped — and removes the button from gamepad selection. Here a premium-only option stays visible but inert. ```tsx title="PremiumOption.tsx" import { Checkbox } from "@lattice-ui/react-checkbox"; export function PremiumOption(props: { hasPremium: boolean }) { return ( ); } ``` ## How it behaves ### Checked state `Checkbox.Root` is controllable on `checked`/`onCheckedChange`, with `defaultChecked` for uncontrolled usage (defaulting to `false`). The state is a `CheckedState` — `true`, `false`, or `"indeterminate"` — so it supports the tri-state "select all" pattern as well as a plain boolean toggle. Activating the checkbox runs a toggle: from `"indeterminate"` it goes to `true`, otherwise it flips the boolean. To set the indeterminate state, drive it yourself through controlled `checked` (typically computed from child selections); the toggle never produces `"indeterminate"` on its own. ### Activation and selection `Checkbox.Root` renders an unstyled activatable `textbutton` that toggles on `Activated`, so click, tap, and gamepad activation all work. Give it a size and colors. It is `Active` and `Selectable` only while enabled, so a disabled checkbox drops out of gamepad selection. With `asChild`, the toggle behavior is merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, and ref win over the child's own props, and event handlers compose (both the slot's `Activated` toggle and any handler you pass on the child run). Use a `textbutton` or `imagebutton` so `Activated` fires. ### Root color is yours The root's `BackgroundColor3` is entirely yours as of 0.7.0. It used to animate between a fixed accent palette with no opt-out; now it is never written by the primitive. Branch on the checked state you already have, and add a response motion of your own if you want the change to ease rather than snap. ### Disabled and required `disabled` (default `false`) blocks both the toggle and direct state changes, and the button removes itself from gamepad selection while disabled. `required` (default `false`) is exposed on context for your own form/validation wiring and submission logic; it does not change interaction behavior on its own. ### Indicator presence and motion `Checkbox.Indicator` is presence-driven by the checked state — it is present whenever `checked` is not `false` (so for both `true` and `"indeterminate"`). It mounts when checked and unmounts when unchecked, holding through any exit transition first. It runs no motion of its own. To animate it, pass `createIndicatorRevealRecipe(size)` from `@lattice-ui/react-motion` as `transition`, built with the size your indicator actually settles at — the recipe grows from zero to that size while fading in. Pass `forceMount` to keep the indicator mounted while unchecked and through its exit, bypassing the presence wrapper so you can drive visibility yourself. With `asChild`, your child element is rendered in place of the frame the part renders and its `Visible` property is bound to the presence state. > **Indeterminate is controlled-only** > > Toggling never yields `"indeterminate"` — it maps indeterminate to `true` and otherwise flips the boolean. Provide `"indeterminate"` through the controlled `checked` prop, usually derived from a group of child checkboxes, and resolve it to `true`/`false` in your `onCheckedChange` handler. > **The primitive owns the box color** > > Before 0.7.0 the root drove your element's `BackgroundColor3` between fixed checked and unchecked accents, with no way to opt out. It no longer writes color at all — in either mode — so a background you set stays exactly as you set it. > **Indicator shows for any non-false state** > > The indicator is visible for both `true` and `"indeterminate"`. If you need a different glyph for the indeterminate state, branch on the checked value inside your indicator's children rather than mounting a second indicator. ## API reference ### Checkbox.Root | Prop | Type | Description | | --- | --- | --- | | `checked` | `boolean \| "indeterminate"` | Controlled checked state. Pair with onCheckedChange. | | `defaultChecked` | `boolean \| "indeterminate"` | Initial checked state for uncontrolled usage. Defaults to false. | | `onCheckedChange` | `(checked: boolean \| "indeterminate") => void` | Called whenever the checked state changes. | | `disabled` | `boolean` | Prevents toggling and removes the button from gamepad selection. Defaults to false. | | `required` | `boolean` | Marks the checkbox as required for your own form/validation wiring. Exposed on context; does not change interaction on its own. Defaults to false. | | `asChild` | `boolean` | Merge checkbox behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactNode` | The checkbox contents, typically a Checkbox.Indicator. Must be a single valid element when asChild is set. | ### Checkbox.Indicator | Prop | Type | Description | | --- | --- | --- | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createIndicatorRevealRecipe(size) built at the size your indicator settles at. | | `forceMount` | `boolean` | Keeps the indicator mounted while unchecked and through its exit motion, instead of unmounting when unchecked. Defaults to false. | | `asChild` | `boolean` | Merge indicator behavior onto the single child element instead of the frame the part renders. The child's Visible property is bound to presence. | | `children` | `React.ReactNode` | The indicator contents shown while checked, such as a checkmark glyph. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Radio Group > Single-selection primitive that owns the chosen value, registers items in order, and moves selection across them with gamepad and arrow-key navigation. Source: https://docs.astra-void.xyz/lattice-ui/components/radio-group/ `@lattice-ui/react-radio-group` · Stable direction · import `RadioGroup` · depends on `runtime`, `focus`, `layer`, `motion` Radio Group is the primitive for picking exactly one option from a set: difficulty selectors, team pickers, quality settings, and any mutually-exclusive choice. `Root` owns the selected value; `Item` registers itself in the group, reflects whether it is checked, and handles activation and directional navigation; `Indicator` reveals a marker on the selected item. Reach for Radio Group when a choice is **single-select**, the options should be **navigable as a unit** with the arrow keys or a gamepad, and selection should **follow focus** the way native radio groups do. For a choice that lives behind a trigger and opens in a popup, [Select](https://docs.astra-void.xyz/lattice-ui/components/select.md) is usually the better fit. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { RadioGroup } from "@lattice-ui/react-radio-group"; ``` ## Anatomy Wrap a set of `Item`s in a `Root`. Each `Item` carries a `value` and may contain an `Indicator` that shows only while that item is checked. `Root` renders no instance of its own — it only provides context — so the surrounding container and layout are yours (see [How it behaves](#root-renders-no-container)). ```tsx title="RadioGroup anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `RadioGroup.Root` | yes | Owns the selected value, group state, item registry, and directional selection movement. Renders no instance. | | `RadioGroup.Item` | yes | A selectable button bound to a `value`; activates, registers as a focus node, and drives arrow navigation. | | `RadioGroup.Indicator` | no | Presence-driven marker rendered only while its parent item is checked. | ## Examples ### Basic usage The smallest possible group: uncontrolled state seeded with `defaultValue` and the default item visuals. Each default item is a `textbutton` labeled with its own `value` and tinted by whether it is checked, which makes it useful for wiring things up before you style anything. Because `Root` renders no container, the group borrows the layout of whatever frame it sits in. ```tsx title="BasicRadioGroup.tsx" import { RadioGroup } from "@lattice-ui/react-radio-group"; export function BasicRadioGroup() { return ( print(`selected: ${value}`)} > ); } ``` ### Controlled group Pass `value` and `onValueChange` when something outside the group needs to read or set the choice — persisting a setting, syncing with a server, or resetting from elsewhere. `onValueChange` fires with the newly selected value and never with `undefined`, so it is safe to store directly. ```tsx title="QualitySetting.tsx" import { useState } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; const QUALITIES = ["Low", "Medium", "High", "Ultra"]; export function QualitySetting(props: { onSave: (quality: string) => void }) { const [quality, setQuality] = useState("Medium"); const selectQuality = (nextQuality: string) => { setQuality(nextQuality); props.onSave(nextQuality); }; return ( {QUALITIES.map((option) => ( ))} ); } ``` ### Orientation and arrow keys `orientation` picks the arrow-key axis: a vertical group (the default) moves on `Up`/`Down`, a horizontal group on `Left`/`Right`. The prop does not lay the items out — pair it with a `uilistlayout` whose `FillDirection` matches so the keys move the way the group looks. Movement clamps at both ends rather than wrapping, so holding `Right` on the last item keeps it selected. ```tsx title="TeamPicker.tsx" import { useState } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; const TEAMS = ["Red", "Blue", "Green"]; export function TeamPicker() { const [team, setTeam] = useState("Red"); return ( {TEAMS.map((option) => ( ))} ); } ``` ### Card-style items with asChild Use `asChild` to project the item behavior — activation, gamepad selection, and the arrow-key wiring — onto your own button, and place an `Indicator` inside it as the selection dot. The item no longer touches your element's color, so branch on the checked state yourself. The indicator only animates if you give it a `transition`. ```tsx title="DifficultyCards.tsx" import { useState } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; const OPTIONS = ["Easy", "Normal", "Hard"]; export function DifficultyCards() { const [difficulty, setDifficulty] = useState("Normal"); return ( {OPTIONS.map((option) => ( ))} ); } ``` ### Disabled items `disabled` on an item makes it inert and removes it from navigation entirely: it cannot be activated, it drops out of gamepad selection, and arrow movement resolves the next item from the enabled entries only — so stepping "through" a disabled item lands on the next enabled one. Setting `disabled` on `Root` freezes the whole group instead. Here a locked mode stays visible but cannot be chosen. ```tsx title="GameModePicker.tsx" import { useState } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; const MODES = [ { value: "Classic", locked: false }, { value: "Ranked", locked: true }, { value: "Endless", locked: false }, ]; export function GameModePicker() { const [mode, setMode] = useState("Classic"); return ( {MODES.map((entry) => ( ))} ); } ``` ### Dynamic item lists Items register with the group on mount and unregister on unmount, and directional navigation walks that registry in **mount order** (see [How it behaves](#item-registration-order)). For a list that grows at runtime, appending to the end keeps mount order and visual order aligned; inserting into the middle of the array renders in the middle but navigates last, so prefer append-only growth or a `LayoutOrder` that mirrors mount order. ```tsx title="LoadoutPicker.tsx" import { useState } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; export function LoadoutPicker() { const [loadouts, setLoadouts] = useState(["Scout", "Tank"]); const [selected, setSelected] = useState("Scout"); const addLoadout = () => { setLoadouts([...loadouts, `Custom ${loadouts.size() - 1}`]); }; return ( {loadouts.map((loadout) => ( ))} ); } ``` ## How it behaves ### Value and selection `RadioGroup.Root` is controllable on `value`/`onValueChange`, with `defaultValue` for uncontrolled usage. With no `defaultValue`, the group starts with nothing selected until an item is activated. An `Item` is checked when its `value` equals the group value, and `onValueChange` is only ever called with a defined string — there is no way to clear the selection through interaction, matching native radio behavior. Activating an item calls the group's `setValue` with that item's value. When `Root` is `disabled`, `setValue` drops every change, so neither activation nor programmatic movement can alter the value; an individual `disabled` item just refuses its own activation. ### Activation An item selects itself on any of three inputs: `Activated` (click or tap), `Return`/`Space` via `InputBegan`, and `SelectionGained` — meaning that on a gamepad, merely moving selection onto an item commits it. The item is `Active` and `Selectable` only while enabled, so a disabled item drops out of gamepad selection and cannot fire any of these paths. With `asChild`, these handlers are merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, and ref win over the child's own props, and event handlers compose. Use a `textbutton` or `imagebutton` so `Activated` fires. ### Orientation and arrow-key navigation `Root` takes an `orientation` of `"vertical"` (default) or `"horizontal"`, which selects the arrow-key axis each item listens on: `Up`/`Down` in a vertical group, `Left`/`Right` in a horizontal one; the other axis is ignored. A matching arrow press asks the group to move selection one step: the group resolves the next entry from the ordered registry, focuses it through the [focus manager](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md), and selects it in the same step — selection follows focus. Movement is computed over **available** entries only — an entry is skipped if it is disabled, its instance is not `Visible`, or it is not `Selectable`. The step index is clamped to the ends of that list, so movement never wraps: pressing past the last item is a no-op. If the currently selected item is itself unavailable (say it became disabled), the next forward move lands on the first available item and the next backward move on the last. ### Item registration order Items register themselves with `Root` on mount and unregister on unmount, each stamped with a monotonically increasing `order` on its first render. An item keeps its order for its whole lifetime; remounting assigns a fresh, higher one. Directional navigation sorts the registry by this order, so navigation tracks **mount order rather than visual position**. Keep the two aligned: render items from one array, append new entries at the end, and lay them out with a `uilistlayout` whose `SortOrder` follows the same sequence. ### Item rendering and color An `Item` renders an unstyled `textbutton` — no size, no label, no colors. It does not render `value` as its text; pass a `Text` of your own, or children. Its `BackgroundColor3` is yours as of 0.7.0. The item used to animate between a fixed accent palette with no opt-out, which is also why `RadioGroup.Item` no longer accepts a `transition` — that prop existed only to time the removed color animation. Derive the color from the checked state and animate it yourself if you want it to ease. ### Indicator presence and motion `RadioGroup.Indicator` is presence-driven by its parent item's checked state: it mounts when the item becomes checked and unmounts when another item is chosen, holding through any exit transition first. It runs no motion of its own — pass `createIndicatorRevealRecipe(size)` from `@lattice-ui/react-motion` as `transition`, built at the size your dot settles at, to grow it from zero while fading in. Pass `forceMount` to keep the indicator mounted regardless of checked state, bypassing the presence wrapper so you can drive visibility yourself. With `asChild`, your child element is rendered in place of the frame the part renders and its `Visible` property is bound to the presence state. ### Root renders no container `RadioGroup.Root` renders no instance — it only provides context — so its children are parented to whatever frame surrounds it. Supply the container and `uilistlayout` yourself, which also means one group's items can share a layout with neighboring elements (a heading, a divider) without extra nesting. > **Selection follows focus** > > On a gamepad, moving selection onto an item selects it immediately (via `SelectionGained`), and arrow navigation both focuses and selects in one step. This matches the native radio pattern, but it means a group should not be the default-selected element if you do not want the first hover to commit a choice. > **Arrow movement clamps at the ends** > > Directional movement clamps to the first and last available items instead of wrapping around. Pressing past either end keeps the current selection, and disabled or invisible items are stepped over as if they were not there. > **The primitive tints the item** > > Before 0.7.0 the item drove your element's `BackgroundColor3` between fixed checked and unchecked accents, and `transition` timed that animation. Both are gone: the item never writes color, and `RadioGroup.Item` no longer accepts `transition`. A background you set stays put. ## API reference ### RadioGroup.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled selected value. Pair with onValueChange. | | `defaultValue` | `string` | Initial selected value for uncontrolled usage. Omit to start with nothing selected. | | `onValueChange` | `(value: string) => void` | Called when the selected value changes. Never called with undefined. | | `disabled` | `boolean` | Disables the whole group; activation, selection, and movement are ignored. Defaults to false. | | `required` | `boolean` | Marks selection as required for your own form/validation wiring. Exposed on context; does not change interaction on its own. Defaults to false. | | `orientation` | `"horizontal" \| "vertical"` | Axis for arrow-key navigation: Up/Down when vertical, Left/Right when horizontal. Defaults to "vertical". | | `children` | `React.ReactNode` | The radio items. Root renders no instance of its own, so children mount into the surrounding container. | ### RadioGroup.Item | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | The value this item represents. Required; compared against the group value to determine checked state. | | `disabled` | `boolean` | Disables this item; it cannot be selected and is skipped by arrow and gamepad navigation. Defaults to false. | | `asChild` | `boolean` | Merge item behavior onto the single child element via Slot instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactNode` | Content placed inside the item button, such as an Indicator. Must be a single element when asChild is set. | ### RadioGroup.Indicator | Prop | Type | Description | | --- | --- | --- | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createIndicatorRevealRecipe(size) built at the size your indicator settles at. | | `forceMount` | `boolean` | Keeps the indicator mounted regardless of checked state, bypassing the presence wrapper so you drive visibility yourself. Defaults to false. | | `asChild` | `boolean` | Render your own marker element instead of the frame the part renders. The child's Visible property is bound to presence. | | `children` | `React.ReactNode` | The marker content shown while the item is checked. | ## Related - [Controllable state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Switch > Boolean toggle primitive that owns checked state and animates the thumb between the ends of the track, while your component owns every part of how it looks. Source: https://docs.astra-void.xyz/lattice-ui/components/switch/ `@lattice-ui/react-switch` · Stable direction · import `Switch` · depends on `runtime`, `motion` Switch is the primitive for an on/off toggle: settings flips, feature enables, and any binary control. The root is the toggleable track that owns checked state, and the thumb slides between the two ends as the state changes. Everything visual — the track's size and color, the thumb's shape — is yours. Reach for Switch when a control is **boolean**, should **toggle on activation**, and wants a **thumb that animates** between off and on positions. If you need a third `"indeterminate"` state or a reveal-style indicator instead of a sliding handle, [Checkbox](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md) is usually the better fit. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Switch } from "@lattice-ui/react-switch"; ``` ## Anatomy `Root` is the toggleable track and is the only required part. `Thumb` is the sliding handle; include it whenever you want the moving indicator. ```tsx title="Switch anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Switch.Root` | yes | The track button: owns checked state and toggles on activation. | | `Switch.Thumb` | no | The handle that animates between the off and on ends of the track. | ## Examples ### Basic usage The smallest switch that is actually visible: uncontrolled state seeded with `defaultChecked`, and just enough styling to see it. The root renders a `textbutton` and the thumb a `frame`, both unstyled, so the size and colors below are the minimum — without them the switch works but draws nothing. ```tsx title="BasicSwitch.tsx" import { Switch } from "@lattice-ui/react-switch"; export function BasicSwitch() { return ( print(`switch is now: ${checked}`)} Size={UDim2.fromOffset(48, 24)} > ); } ``` ### Controlled settings toggle Pass `checked` and `onCheckedChange` when something outside the switch needs to read or set the value — persisting a setting, syncing with a server, or resetting from elsewhere. Here a music toggle keeps local state for instant feedback and forwards every change to a save callback. ```tsx title="MusicToggle.tsx" import { useState } from "@rbxts/react"; import { Switch } from "@lattice-ui/react-switch"; export function MusicToggle(props: { onSave: (enabled: boolean) => void }) { const [enabled, setEnabled] = useState(true); return ( { setEnabled(checked); props.onSave(checked); }} Size={UDim2.fromOffset(48, 24)} > ); } ``` ### Custom track and thumb with asChild Use `asChild` on the root when you need a `frame` track rather than the `textbutton` the root renders, and on the thumb to slide your own handle. Track color is always yours — branch on the controlled state directly. The thumb still animates: its travel resolves to the track width minus the thumb width, whatever size you make either one. ```tsx title="StyledSwitch.tsx" import { useState } from "@rbxts/react"; import { Switch } from "@lattice-ui/react-switch"; export function StyledSwitch() { const [enabled, setEnabled] = useState(false); return ( ); } ``` > **Track color is yours** > > Before 0.7.0 the root could animate its own `BackgroundColor3` through `trackColorMode`, `trackOnColor`, `trackOffColor` and `disabledTrackColor`. Those props are gone, along with the `SwitchTrackColorMode` type. Derive the color from the checked state you already control, as above; if you want it to ease rather than snap, animate it yourself. ### Disabled state `disabled` blocks toggling entirely — activation is ignored and `setChecked` calls from context are dropped — and removes the switch from gamepad selection. Nothing about the appearance changes on its own, so render the inert look yourself. Here a premium-only option stays visible but inactive. ```tsx title="PremiumToggle.tsx" import { Switch } from "@lattice-ui/react-switch"; export function PremiumToggle(props: { hasPremium: boolean }) { const disabled = !props.hasPremium; return ( ); } ``` ### Custom thumb size The thumb's motion has no transition prop — the slide is a fixed short settle — but the geometry adapts to your handle on its own. Travel resolves to the track width minus the thumb width, and the thumb stays vertically centered at any height, so you do not have to compute insets or match sizes. ```tsx title="LargeThumbSwitch.tsx" import { useState } from "@rbxts/react"; import { Switch } from "@lattice-ui/react-switch"; export function LargeThumbSwitch() { const [enabled, setEnabled] = useState(false); return ( ); } ``` ## How it behaves ### Checked state `Switch.Root` is controllable on `checked`/`onCheckedChange`, with `defaultChecked` for uncontrolled usage (defaulting to `false`). The state is a plain boolean — activating the root flips it. When `disabled`, both the toggle and direct `setChecked` calls from context are ignored, so the state cannot change until the switch is re-enabled. ### Activation and selection `Switch.Root` renders an activatable `textbutton` that toggles on `Activated`, so click, tap, and gamepad activation all work. It carries no size, color or label of its own — pass those as props. It is `Active` and `Selectable` only while enabled, so a disabled switch drops out of gamepad selection. With `asChild`, the toggle behavior is merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, and ref win over the child's own props, and event handlers compose (both the slot's `Activated` toggle and any handler you pass on the child run). Use an element that fires `Activated`, such as a `textbutton` or `imagebutton`. ### Track color The track's `BackgroundColor3` is entirely yours. Derive it from the same `checked` state you pass in, and animate it yourself if you want it to ease rather than snap. > **Removed in 0.7.0** > > `trackColorMode`, `trackOnColor`, `trackOffColor` and `disabledTrackColor` no longer exist on `Switch.Root`, and neither does the `SwitchTrackColorMode` type. See [Migration](https://docs.astra-void.xyz/lattice-ui/reference/migration.md). ### The thumb `Switch.Thumb` animates between the two ends of the track as `checked` changes. Checked parks the thumb's trailing edge on the track's trailing edge; unchecked parks its leading edge on the leading edge. Because `AnchorPoint` and `Position` interpolate together, the travel resolves to the track width minus the thumb width for **any** thumb width — the primitive never needs to know how wide you made it, and a thumb sized through a child element, a size constraint, or a layout works as well as one with a declared `Size`. The same pairing on the Y axis keeps the thumb centered in the track at any height. Motion owns `AnchorPoint` and `Position` under a `layout` target contract, so both are dropped from anything you pass rather than being written and clobbered on the next frame. Style the thumb with size, color, corners and children instead. Under `asChild`, the primitive wraps your element in a transparent frame that it animates, and pins your element to `Position` (0, 0) inside that wrapper — so put your styling on the child, but leave its `Position` alone. The thumb is always mounted regardless of checked state; unlike `Checkbox.Indicator` it is not presence-driven, so there is no `forceMount` prop. ### Motion The thumb slide is a fixed short response settle — a 0.08s swift, responsive tween — so toggling feels immediate but smooth rather than snapping. Neither `Root` nor `Thumb` exposes a `transition` prop; you shape the feel through geometry instead. Nothing else on the switch animates: since 0.7.0 the track color is yours, so any color transition is yours to drive too. > **Roblox gotchas** > > The root is a Roblox button (`textbutton`, or your slotted element via `asChild`). It is made `Active` and `Selectable` only while enabled, so a disabled switch drops out of gamepad selection. Give the track a real size — the thumb's travel is measured against it. ## API reference ### Switch.Root | Prop | Type | Description | | --- | --- | --- | | `checked` | `boolean` | Controlled checked state. Pair with onCheckedChange. | | `defaultChecked` | `boolean` | Initial checked state for uncontrolled usage. Defaults to false. | | `onCheckedChange` | `(checked: boolean) => void` | Called whenever the checked state changes. | | `disabled` | `boolean` | Prevents toggling and removes the switch from gamepad selection. Defaults to false. | | `asChild` | `boolean` | Merge the track behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactNode` | The track contents, typically a Switch.Thumb. Must be a single valid element when asChild is set. | | `…TextButton props` | `Partial>` | Forwarded onto the rendered textbutton and type-checked against it. Active and Selectable are owned by the primitive, derived from disabled. | ### Switch.Thumb Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error. The primitive owns `AnchorPoint` and `Position` under a layout motion contract, so values you pass for those are ignored. | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the animated thumb onto the single child element instead of the frame the part renders. The child is pinned at Position (0, 0) inside the animated wrapper. | | `children` | `React.ReactNode` | The thumb contents. Must be a single valid element when asChild is set. | | `…Frame props` | `Partial>` | Forwarded onto the rendered frame and type-checked against it. AnchorPoint and Position are dropped — motion owns the thumb's placement. Travel is derived from the track and thumb widths, so no Size is required. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Toggle Group > Selection primitive that coordinates a set of toggle items in single- or multiple-select mode, owning pressed state and motion while you own the item visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/toggle-group/ `@lattice-ui/react-toggle-group` · Stable direction · import `ToggleGroup` · depends on `runtime`, `focus`, `motion` Toggle Group is the primitive for a related set of toggles that share selection state: view switches, filter chips, text-alignment pickers, and difficulty selectors. `Root` owns the selected value and tells each `Item` whether it is pressed, so your items only render their two visual states. Reach for Toggle Group when several toggles belong together and should behave as **one control** — either a **single-select** group where picking one clears the others, or a **multiple-select** group where each item toggles independently. For a lone on/off control, [Checkbox](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md) or [Switch](https://docs.astra-void.xyz/lattice-ui/components/switch.md) is the better fit. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { ToggleGroup } from "@lattice-ui/react-toggle-group"; ``` ## Anatomy A `Root` wrapping one `Item` per option. `Root` requires a `type` that fixes the whole group as single- or multiple-select. ```tsx title="ToggleGroup anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `ToggleGroup.Root` | yes | Owns the selected value(s), the select mode, and group-wide disabled state. | | `ToggleGroup.Item` | yes | One toggle; reads its pressed state from `Root`. | ## Examples ### Single-select toolbar The smallest useful group: an uncontrolled single-select mode picker seeded with `defaultValue`. Each default item renders as a `textbutton` labeled with its `value`, which makes it easy to wire the group up before styling anything. In single mode, activating the already-pressed item clears the selection — `onValueChange` then fires with `undefined` — so a toolbar like this can also be fully deselected. ```tsx title="HotbarModePicker.tsx" import { ToggleGroup } from "@lattice-ui/react-toggle-group"; export function HotbarModePicker() { return ( print(`active mode: ${mode}`)} > ); } ``` ### Multiple-select filters Switch `type` to `"multiple"` and the value becomes a `string[]`: each item toggles independently, and the array preserves the order in which items were selected. `defaultValue` seeds the initial set for uncontrolled usage (it defaults to an empty array), and `onValueChange` receives the full next array on every toggle. ```tsx title="ItemFilters.tsx" import { ToggleGroup } from "@lattice-ui/react-toggle-group"; export function ItemFilters() { return ( print(`active filters: ${filters.join(", ")}`)} > ); } ``` ### Controlled group Pass `value` and `onValueChange` when something outside the group reads or sets the selection. Here a start button reads the picked difficulty and stays inert while the selection is cleared — in single mode the controlled value is `string | undefined`, so design the surrounding UI around the `undefined` case. ```tsx title="DifficultyPicker.tsx" import { useState } from "@rbxts/react"; import { ToggleGroup } from "@lattice-ui/react-toggle-group"; export function DifficultyPicker(props: { onStart: (difficulty: string) => void }) { const [difficulty, setDifficulty] = useState("normal"); return ( { if (difficulty !== undefined) { props.onStart(difficulty); } }, }} Size={UDim2.fromOffset(160, 32)} Text={difficulty !== undefined ? `Start (${difficulty})` : "Pick a difficulty"} TextColor3={Color3.fromRGB(240, 244, 252)} /> ); } ``` ### Disabled group and disabled items `disabled` works at two levels. On `Root` it freezes the whole control: every item ignores activation, and `Root`'s toggle logic is short-circuited so nothing can change the value while it is set. On an `Item` it disables just that option while the rest of the group keeps working. Here the loadout is locked during a match, and one class is additionally locked behind premium. ```tsx title="LoadoutModes.tsx" import { ToggleGroup } from "@lattice-ui/react-toggle-group"; export function LoadoutModes(props: { inMatch: boolean; hasPremium: boolean }) { return ( ); } ``` A disabled item is marked inactive (`Active={false}`) but still renders its pressed state, so a locked-in selection stays visibly selected. ### Custom pressed visuals with asChild Use `asChild` on `Root` when you need a different container class, and on each `Item` to project the toggle behavior onto your own button. Since 0.7.0 the primitive never writes an item's colors, so read the pressed state from `Root` and set `BackgroundColor3` and `TextColor3` yourself (see [How it behaves](#item-colors)). ```tsx title="StyledViewSwitch.tsx" import { useState } from "@rbxts/react"; import { ToggleGroup } from "@lattice-ui/react-toggle-group"; const VIEWS = ["grid", "list", "detail"]; export function StyledViewSwitch() { const [view, setView] = useState("grid"); return ( {VIEWS.map((viewName) => ( ))} ); } ``` `ToggleGroup.Item` no longer accepts a `transition` as of 0.7.0 — the prop existed only to time the color animation that has been removed. Animate your own colors if you want the change to ease. ## How it behaves ### Value state `ToggleGroup.Root` is controllable. Pass `value` and `onValueChange` to control the selection, or omit them and pass `defaultValue` to let the group own it — `onValueChange` still fires on every change in uncontrolled usage. Each `Item` reads its own pressed state from the group through context, so controlled and uncontrolled usage behave identically. Activating an item routes through `Root`, which updates the shared value. ### Single vs multiple The required `type` prop fixes the group's behavior and the shape of its value: - **`type="single"`** — `value`/`defaultValue` are `string`, and `onValueChange` receives `string | undefined`. Selecting an item replaces the current value; selecting the already-selected item clears it, so the value becomes `undefined`. - **`type="multiple"`** — `value`/`defaultValue` are `string[]`, and `onValueChange` receives `string[]`. Each item toggles independently: a newly selected value is appended to the end, so the array preserves selection order. Incoming values (controlled `value`, `defaultValue`, and the array handed to `onValueChange`) are normalized — duplicate and non-string entries are stripped. Because the value type and the `onValueChange` signature are a discriminated union over `type`, set `type` as a literal so TypeScript can narrow to the right props. ### Activation and selection `ToggleGroup.Item` toggles on `Activated` and on `Return`/`Space` via `InputBegan`, so mouse, touch, keyboard, and gamepad activation all work. It renders an unstyled `textbutton` with `AutoButtonColor` off, and does **not** render `value` as its text — pass a `Text` of your own. Items are rendered with `Selectable={false}` on the button itself — manage gamepad selection at the surrounding layout if you need it. With `asChild`, the toggle behavior is merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, and motion ref win over the child's own props, and event handlers compose (both the primitive's `Activated`/`InputBegan` handlers and any you pass on the child run). Use a `textbutton` or `imagebutton` so `Activated` fires. ### Group container `Root` renders a transparent, borderless frame with no size of its own — it is a context boundary, not a laid-out surface. Put a `uilistlayout` (or your own positioning) inside it to arrange the items, and pass a `Size` so it reserves space in a flow layout. ### Disabled state `disabled` on `Root` disables the entire group; `disabled` on an `Item` disables just that one. A disabled item ignores both activation and keyboard toggles and is marked inactive (`Active={false}`), but keeps rendering its pressed visuals. Group-level disabled additionally short-circuits `Root`'s toggle logic, so no value change can get through while it is set — including from an item that missed the flag. ### Item colors An item's `BackgroundColor3` and `TextColor3` are entirely yours as of 0.7.0. Each item used to animate between fixed active and inactive palettes with no way to opt out; the primitive now writes neither, in either mode. Read the pressed state from `ToggleGroup.Root` and branch on it, adding your own response motion if you want the change to ease rather than snap. > **Single mode can clear to undefined** > > Activating the pressed item in a single-select group deselects it; there is no built-in "always keep one selected" mode. If your UI requires a selection, run controlled and ignore the clear: `onValueChange={(next) => next !== undefined && setValue(next)}`. > **The container has no size of its own** > > The `Root` frame is transparent and unsized, so it reserves no space in a `uilistlayout` or `uigridlayout` parent. Pass a `Size` — or `asChild` with your own container — whenever the group sits in a flow layout. > **Removed in 0.7.0** > > Before 0.7.0 `ToggleGroup.Item` animated fixed active/inactive palettes that could not be overridden, and took a `transition` to time them. Both are gone — the item writes no colors and accepts no `transition`. Colors you set stay exactly as you set them. ## API reference ### ToggleGroup.Root `Root` takes the common props below plus the single- or multiple-mode props selected by `type`. | Prop | Type | Description | | --- | --- | --- | | `type` | `"single" \| "multiple"` | Required. Fixes the group as single- or multiple-select and determines the value shape. Pass a literal so TypeScript narrows the union. | | `value` | `string \| string[]` | Controlled selection. string in single mode, string[] (normalized: duplicates and non-strings stripped) in multiple mode. Pair with onValueChange. | | `defaultValue` | `string \| string[]` | Initial selection for uncontrolled usage. string in single mode; string[] (default []) in multiple mode. | | `onValueChange` | `(value: string \| undefined) => void \| (value: string[]) => void` | Called on every selection change, controlled or not. Receives string \| undefined in single mode and the full next string[] in multiple mode. | | `disabled` | `boolean` | Disables every item and short-circuits the group's toggle logic so no value change gets through. Defaults to false. | | `asChild` | `boolean` | Render the single child element as the group container instead of the frame the part renders. | | `children` | `React.ReactNode` | The toggle items and any layout. Must be a single valid element when asChild is set. | ### ToggleGroup.Item | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Required. Identifies this item within the group. Since 0.7.0 it is not rendered as the button's text — pass Text yourself. | | `disabled` | `boolean` | Disables just this item — activation and keyboard toggles are ignored and the button is marked inactive. Defaults to false. | | `asChild` | `boolean` | Merge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactNode` | Rendered inside the item button, or the element to project onto. Must be a single element when asChild is set. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Slider > Single-thumb slider primitive that owns clamped, stepped value state and pointer-drag plus keyboard adjustment while you own the track, range, and thumb visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/slider/ `@lattice-ui/react-slider` · Feature limited · import `Slider` · depends on `runtime`, `focus`, `motion` Slider is the primitive for picking a number from a continuous range: volume, sensitivity, brightness, and any "drag to set a value" control. It owns the value — clamping it to `[min, max]` and snapping it to `step` — and translates pointer drags and keyboard input on the track and thumb into value changes, so your component only renders the track, the filled range, and the thumb. Reach for Slider when a control needs a **single numeric value**, **drag interaction on a track**, and **predictable clamping and stepping** without you doing the pointer math. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Slider } from "@lattice-ui/react-slider"; ``` ## Anatomy `Root`, `Track`, and `Thumb` form the minimum useful slider. `Range` is optional but is the usual way to show the filled portion up to the current value. ```tsx title="Slider anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Slider.Root` | yes | Owns clamped/stepped value state, drag lifecycle, and orientation, shared through context. | | `Slider.Track` | yes | The draggable rail; an input on it starts a drag toward that position. | | `Slider.Range` | no | The filled portion from the start of the track to the current value. | | `Slider.Thumb` | yes | The draggable handle positioned at the current value; also handles keyboard adjustment. | > **Single thumb only** > > This release of Slider is **single-thumb only**. There is no range/dual-thumb mode, and `value`/`defaultValue` are a single `number`, not an array. Compose two independent sliders or track your own state if you need a min/max range until multi-thumb lands. ## Examples ### Basic volume slider Uncontrolled state seeded with `defaultValue`. Every part renders unstyled, so the track, range and thumb below each carry their own size and color — the primitive owns only the geometry it computes from the value. `onValueChange` would fire on every drag tick, but for a "save on release" control you only need `onValueCommit` — it fires once when the interaction ends, which is the right moment to persist the setting or send it over the network. ```tsx title="VolumeSlider.tsx" import { Slider } from "@lattice-ui/react-slider"; export function VolumeSlider(props: { onSave: (volume: number) => void }) { return ( props.onSave(volume)} > ); } ``` ### Controlled slider with live readout Pass `value` and `onValueChange` when something outside the slider needs the number as it moves — here a label that tracks the drag in real time. `Slider.Root` renders no instance of its own (it is a context provider), so the track participates directly in the surrounding row layout. ```tsx title="BrightnessSlider.tsx" import { useState } from "@rbxts/react"; import { Slider } from "@lattice-ui/react-slider"; export function BrightnessSlider() { const [brightness, setBrightness] = useState(70); return ( ); } ``` ### Fine-grained steps with min and max `min`, `max`, and `step` shape the whole value space: every value — incoming, dragged, or keyed — is clamped to `[min, max]` and snapped to the nearest multiple of `step` counted from `min`. A camera-sensitivity slider from 0.1 to 2 in 0.05 increments lands only on 0.10, 0.15, 0.20, and so on; format the readout yourself since the value is a plain number. ```tsx title="SensitivitySlider.tsx" import { useState } from "@rbxts/react"; import { Slider } from "@lattice-ui/react-slider"; export function SensitivitySlider(props: { onCommit: (sensitivity: number) => void }) { const [sensitivity, setSensitivity] = useState(1); return ( ); } ``` ### Vertical orientation Set `orientation="vertical"` for a column-style control such as a mixer channel. The default track becomes 10x220, the range fills from the bottom, and the thumb travels bottom-to-top; dragging maps the pointer's Y position to the value, with the top of the track as `max`. ```tsx title="AmbienceChannel.tsx" import { useState } from "@rbxts/react"; import { Slider } from "@lattice-ui/react-slider"; export function AmbienceChannel() { const [level, setLevel] = useState(60); return ( ); } ``` ### Custom visuals with asChild Every part accepts `asChild` to project its behavior onto your own element. The track slot carries the drag-start handler, ref, and selection props; the thumb slot additionally centers your element with a forced `AnchorPoint` of (0.5, 0.5). `Slider.Range` is different: your child is stretched to fill the animated fill frame (its `Position` and `Size` are overridden), so style it with color, gradients, and corners rather than sizing it yourself. ```tsx title="StyledSlider.tsx" import { useState } from "@rbxts/react"; import { Slider } from "@lattice-ui/react-slider"; export function StyledSlider() { const [value, setValue] = useState(50); return ( ); } ``` ### Keyboard adjustment When the thumb has input focus, keyboard input adjusts the value without a drag: `Right`/`Up` add `step`, `Left`/`Down` subtract it, `PageUp`/`PageDown` move by `step * 10`, and `Home`/`End` jump to `min`/`max`. Each of those keys both changes **and commits** the value, so a keypress-heavy control fires `onValueCommit` once per press — keep the commit handler cheap or debounce expensive work yourself. `Return`/`Space` commits the current value without changing it. With `step={1}`, this FOV slider nudges by 1 per arrow press and 10 per page press. ```tsx title="FieldOfViewSlider.tsx" import { useState } from "@rbxts/react"; import { Slider } from "@lattice-ui/react-slider"; export function FieldOfViewSlider(props: { onCommit: (fov: number) => void }) { const [fov, setFov] = useState(80); return ( ); } ``` ## How it behaves ### Value, clamping, and stepping The value is a single `number`. `Slider.Root` is controllable via `value`/`onValueChange`, or uncontrolled via `defaultValue` (which itself defaults to `min`). Every value — incoming, dragged, or keyed — is clamped to `[min, max]` and snapped to the nearest multiple of `step` counted from `min`. `min`/`max` default to `0`/`100` and are normalized so the lower bound is always the smaller of the two; `step` defaults to `1`, and a zero or negative `step` falls back to `1`. ### Change vs. commit `onValueChange` fires as the value moves — every drag tick and every value-changing keypress. `onValueCommit` fires once at the end of an interaction: when a drag is released, after each keyboard adjustment, or on a keyboard `Return`/`Space`. Treat `onValueChange` as the "live preview" channel (update a label, adjust volume locally) and `onValueCommit` as the "persist" channel (save the setting, fire a remote). ### Rendering and layout `Slider.Root` renders no instance — it is purely a context provider — so `Slider.Track` is the outermost GuiObject and sits directly in the parent layout. The default track is a `frame` sized 260x10 (horizontal) or 10x220 (vertical); the default range is an animated fill `frame`; the default thumb is a 16x16 `textbutton` anchored at its center. All three can be replaced with `asChild`. ### Dragging A pointer press (`MouseButton1` or `Touch`) on either `Slider.Track` or `Slider.Thumb` starts a drag and immediately jumps the value to the pressed position. While dragging, the root listens to `UserInputService` input changes and updates the value as the pointer moves — even after it leaves the track — then commits on release. Touch drags are tracked per input object so multi-touch doesn't cross wires. Drag listeners are cleaned up when the slider unmounts. ### Keyboard adjustment `Slider.Thumb` handles keyboard input when focused: arrow `Right`/`Up` increase and `Left`/`Down` decrease by `step`; `PageUp`/`PageDown` move by `step * 10`; `Home`/`End` jump to `min`/`max`. Each of these both changes and commits the value. `Return`/`Space` commits the current value without changing it. These are keyboard key codes — gamepad buttons are not mapped to value changes in this release. ### Orientation Set `orientation` to `"horizontal"` (default) or `"vertical"`. It governs which pointer axis maps to the value, where the default track sizes itself, and how `Slider.Range` and `Slider.Thumb` position themselves — horizontally the range grows from the left and the thumb tracks left-to-right; vertically the range grows from the bottom and the thumb tracks bottom-to-top. ### Composition with asChild `Slider.Track` and `Slider.Thumb` merge their behavior onto your single child through the shared `Slot`: the slot's `Active`, `Selectable`, ref, and `InputBegan` drag/keyboard handling win over the child's own props, and the thumb also forces `AnchorPoint` to (0.5, 0.5) so it stays centered on its position. `Slider.Range` with `asChild` keeps the animated fill frame and stretches your child to fill it — the child's `Position` and `Size` are overridden — so express its look through color, gradients, corners, and strokes. ### Motion `Slider.Range` and `Slider.Thumb` animate toward their target position/size with a response recipe, using a slightly snappier settle while a drag is in progress so the handle stays under the pointer. > **Roblox gotchas** > > Drag uses Roblox pointer input on the track and thumb, so those nodes are made `Active` and `Selectable` only while the slider is enabled — when `disabled`, input is ignored and selection is removed. Keyboard adjustment fires through the thumb's `InputBegan`, so the thumb must be able to receive selection (e.g. via gamepad) for arrow/page keys to reach it. > **Keyboard input commits on every press** > > Arrow, page, and `Home`/`End` presses call `onValueCommit` as well as `onValueChange` — one commit per keypress, unlike a drag's single commit on release. If commit triggers network traffic, debounce it in your handler rather than assuming one commit per interaction. > **Root is not a container** > > `Slider.Root` renders no GuiObject, so you cannot size or position "the slider" through it. Size the track (or your `asChild` track element) instead, and wrap the slider in your own frame when it needs padding or a background. ## API reference ### Slider.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `number` | Controlled value. Pair with onValueChange. | | `defaultValue` | `number` | Initial value for uncontrolled usage. Defaults to min. | | `onValueChange` | `(value: number) => void` | Called continuously as the value changes during drag or keyboard input. | | `onValueCommit` | `(value: number) => void` | Called once when an interaction ends (drag release or keyboard commit). | | `min` | `number` | Lower bound of the range. Defaults to 0. | | `max` | `number` | Upper bound of the range. Defaults to 100. | | `step` | `number` | Increment the value snaps to. Defaults to 1; non-positive values fall back to 1. | | `orientation` | `"horizontal" \| "vertical"` | Axis the slider runs along. Defaults to "horizontal". | | `disabled` | `boolean` | Disables drag and keyboard input and removes the track/thumb from selection. Defaults to false. | | `children` | `React.ReactNode` | The slider parts. Root renders no instance of its own. | ### Slider.Track | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge track behavior (drag start, ref, selection) onto the single child element instead of rendering the default frame. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set; otherwise rendered inside the default track (typically the Range and Thumb). | ### Slider.Range | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the animated fill onto the single child element; the child is stretched to fill the animated range frame, overriding its Position and Size. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Slider.Thumb | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge thumb behavior (drag start, keyboard handling, ref, selection, centered AnchorPoint) onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Select > Single-value selection primitive that owns open and value state, registers items in order, and anchors popper-positioned content in a portal while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/select/ `@lattice-ui/react-select` · Feature limited · import `Select` · depends on `runtime`, `focus`, `layer`, `motion`, `popper` Select is the primitive for picking one value from a list: difficulty pickers, region menus, sort dropdowns, and any "choose one" control. It coordinates open state, the selected value, item registration, popper positioning, and outside-press dismissal so your component only has to render a trigger, a value label, and the items. Reach for Select when a control needs to **hold a single value**, **open an anchored popup** over a trigger, and **dismiss predictably** on selection or an outside interaction. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Select } from "@lattice-ui/react-select"; ``` ## Anatomy `Root`, `Trigger`, `Portal`, `Content`, and at least one `Item` form the minimum useful select. `Value`, `Group`, `Label`, and `Separator` are optional and help you structure the trigger label and the list. ```tsx title="Select anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Select.Root` | yes | Owns open + value state and the item registry, shared through context. | | `Select.Trigger` | yes | A button that toggles the content open and closed. | | `Select.Value` | no | Renders the selected item's text, or a placeholder when nothing is chosen. | | `Select.Portal` | yes | Renders the content into a `ScreenGui` outside the local tree. | | `Select.Content` | yes | The popper-positioned, dismissable list surface. | | `Select.Item` | yes | A selectable option that registers itself and sets the value on activation. | | `Select.Group` | no | A non-semantic container for grouping related items. | | `Select.Label` | no | A heading for a group. | | `Select.Separator` | no | A thin divider between items or groups. | ## Examples ### Basic select A controlled root, a trigger button with a `Value` label inside, and a handful of items on a plain list surface. Two things this example has to supply that older versions did for you: each `Select.Item` needs its **label as a child** — `textValue` no longer renders — and every part needs its own styling. `textValue` still matters, but only for what `Select.Value` shows in the closed trigger (see [How it behaves](#the-value-label)); it draws nothing in the list. ```tsx title="RegionSelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; const REGIONS = ["NA East", "NA West", "Europe", "Asia"]; export function RegionSelect() { const [region, setRegion] = useState(); return ( {REGIONS.map((value) => ( ))} ); } ``` ### Saving a setting on change Wire `value`/`onValueChange` to persist a selection the moment it happens — a graphics quality setting written back to your settings store on every change. `onValueChange` fires once per accepted selection: presses on disabled items never reach it, and it only ever reports a `string`, so the handler is a safe place to save. ```tsx title="GraphicsQualitySelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; const QUALITY_LEVELS = ["Low", "Medium", "High", "Ultra"]; export function GraphicsQualitySelect(props: { savedQuality: string; onSave: (quality: string) => void; }) { const [quality, setQuality] = useState(props.savedQuality); return ( { setQuality(value); props.onSave(value); }} > {QUALITY_LEVELS.map((level) => ( ))} ); } ``` ### Grouped options `Group`, `Label`, and `Separator` structure a longer list. Groups are purely visual — item registration order and value resolution ignore them. All three render unstyled: `Group` is a bare frame with no layout of its own, `Label` renders no copy (pass `Text`), and `Separator` draws nothing until you give it a `Size` and `BackgroundColor3`. ```tsx title="SortSelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; export function SortSelect() { const [sort, setSort] = useState("Newest"); return ( ); } ``` ### Disabled options A disabled item stays visible but inert: pressing it does nothing — the value is unchanged and the content stays open — and it skips its hover motion. Disabled items are also excluded from value resolution, so if the current value points at one (here, a premium tier after the pass expires), the root re-selects the first enabled item the next time the items mount and reports it through `onValueChange`. ```tsx title="ServerTierSelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; export function ServerTierSelect(props: { hasPremium: boolean }) { const [tier, setTier] = useState("Standard"); return ( ); } ``` ### Custom trigger and value with asChild Use `asChild` to project the trigger behavior onto your own button and the resolved text onto your own label. The trigger slot merges its `Active` state, its `Activated`/`InputBegan` handlers, and its ref onto your child, so pass an activatable `textbutton` or `imagebutton`. The `Value` slot drives the child's `Text` property with the resolved label — `placeholder` while nothing is selected, the matching item's `textValue` otherwise — so its child must be a text-bearing element; everything else about the label (color, alignment, size) is yours. ```tsx title="LoadoutSelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; export function LoadoutSelect() { const [loadout, setLoadout] = useState(); return ( ); } ``` ### Tuning placement and offsets `Content` anchors to the trigger with popper defaults of `placement="bottom"`, `sideOffset={0}`, `alignOffset={0}`, and `collisionPadding={8}`. A select sitting in a bottom HUD bar should open upward instead — and if there is not enough room on the preferred side, the content flips to the opposite side automatically, staying at least `collisionPadding` pixels from the viewport edges. ```tsx title="HudSortSelect.tsx" import { useState } from "@rbxts/react"; import { Select } from "@lattice-ui/react-select"; export function HudSortSelect() { const [sort, setSort] = useState("Rarity"); return ( ); } ``` > **Single value only** > > This release of Select is **single-value only**. There is no multi-select mode, and `value`/`defaultValue` are a single `string`, not an array. Track multiple selections with separate controls or your own state until multi-select lands. ## How it behaves ### Open state `Select.Root` is controllable. Pass `open` and `onOpenChange` to control it, or `defaultOpen` to run uncontrolled (defaults to `false`). `Select.Trigger` toggles open on activation and on `Return`/`Space`, and selecting an item closes it. A disabled root blocks *opening* only — an already-open content can still close, so dismissal keeps working if you disable the select while it is open. `Select.Trigger` also takes its own `disabled` prop, which combines with the root's. The trigger renders an unstyled `textbutton` — give it a size and colors. With `asChild`, the slot merges `Active`, the `Activated` and `InputBegan` handlers, and its ref onto your single child element, so use an activatable button class. ### Value and selection The selected value is a single `string`. `Select.Root` is controllable via `value`/`onValueChange`, or uncontrolled via `defaultValue`. Each `Select.Item` registers itself with the root on mount — recording its `value`, `textValue`, disabled state, and document order — so while the content is open the root knows the full ordered set of options. Activating an item (click, `Return`, or `Space`) sets the value and closes the content. Selection is guarded twice: a disabled item ignores activation outright (no value change, content stays open), and `setValue` refuses any value that resolves to a disabled registered item. On top of that, the root continuously reconciles the current value against the registry: if the value has no matching *enabled* registered item, it re-selects the first enabled item — or clears the value when no enabled item is registered. The fallback re-selection is reported through `onValueChange`; the clear is not, so `onValueChange` only ever receives a `string`. ### Items register only while mounted Item registration is tied to component lifetime, and items live inside `Select.Content`, which unmounts while the select is closed (unless `forceMount` is set). Two practical consequences: - **Run the value controlled.** With the content closed the registry is empty, so the reconciliation described above clears *uncontrolled* value state — including `defaultValue`, which is dropped before the content ever opens. Controlled `value` is unaffected: the clear never overrides your prop and never calls `onValueChange`. - **`textValue` only resolves while items are mounted.** With the content closed, `Select.Value` cannot look up the selected item's `textValue` and falls back to the raw value string. Either keep value strings display-ready (as in the examples above) or pass `forceMount` on `Content` to keep items registered while closed. ### The value label `Select.Value` resolves its text through a chain: the registered item's `textValue`, then the raw value string, then `placeholder` (default `""`) when no value is selected. Writing `Text` *is* this part's behavior, so it owns that property in both modes — but only that one. It no longer dims its color while showing the placeholder; branch on your own state if you want a muted placeholder. With `asChild`, the slot drives your child's `Text`, so pass a text-bearing element. ### Positioning `Select.Content` is positioned with popper, anchored to the trigger. Control the side with `placement` (`"top" | "bottom" | "left" | "right"`, defaulting to `"bottom"`), push it away from the trigger with `sideOffset` (default `0`), slide it along the cross axis with `alignOffset` (default `0`), and keep it inside the viewport with `collisionPadding` (default `8`). The content is measured after mount and flipped to the opposite side automatically when it would collide with a viewport edge; until the first measurement completes it is parked offscreen, so you never see an unpositioned frame. ### Dismissal `Select.Content` participates in dismissable-layer behavior in non-modal mode: interaction behind it is not blocked, but an outside press closes it. Use `onPointerDownOutside` and `onInteractOutside` to observe those interactions before the content dismisses. While open, a focus scope wraps the content and restores focus to the previously focused element on close; it does not trap focus. ### Motion and presence `Select.Content` runs no motion of its own. Pass a `transition` to animate it: `createPopperEntranceRecipe(placement)` matches the `frame` the content renders, on the default path and under `asChild` where your own element replaces it. It travels from the resolved placement side. `forceMount` keeps the content mounted while closed and through its exit. Items animate their own hover state: `MouseEnter`/`MouseLeave` and gamepad `SelectionGained`/`SelectionLost` drive a background-color settle using the selection response recipe, skipped while the item is disabled. With `asChild` on an item, the slot merges `Active`, the activation and hover handlers, and its ref onto your child element. > **Roblox gotchas** > > `Select.Portal` renders into a `ScreenGui` on the player's `PlayerGui`, not the local component tree; use `container` and `displayOrderBase` to target a specific GUI and order it against other layers. Items respond to gamepad `SelectionGained`/`SelectionLost` for hover state, but Select does **not** install Roblox native directional selection or keyboard navigation between items — the trigger and items render with `Selectable` set to `false` (the `asChild` slot pins it too), so build gamepad list traversal with your own selectable elements around the primitive if you need it. ## API reference ### Select.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled selected value. Pair with onValueChange. Values resolving to disabled items re-select the first enabled item once items mount. | | `defaultValue` | `string` | Initial value for uncontrolled usage. Only honored while a matching enabled item is mounted — prefer controlled value, or forceMount the content. | | `onValueChange` | `(value: string) => void` | Called once per accepted selection, including fallback re-selection away from a disabled value. Never called with undefined. | | `open` | `boolean` | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called when the open state changes. | | `disabled` | `boolean` | Disables the whole select: the trigger cannot open and values cannot change. An already-open content can still close. Defaults to false. | | `required` | `boolean` | Marks the select as required; surfaced through context for consumer use. Does not change interaction on its own. Defaults to false. | | `children` | `React.ReactNode` | The select parts. | ### Select.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge trigger behavior (Active, Activated/InputBegan handlers, ref) onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `disabled` | `boolean` | Prevents this trigger from opening the select, in addition to the root's disabled state. Defaults to false. | | `children` | `React.ReactNode` | Rendered inside the trigger button (typically a Select.Value). Must be a single element when asChild is set. | ### Select.Value | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Drive the single child element's Text property with the resolved label instead of the textlabel the part renders. The child must be a text-bearing element. | | `placeholder` | `string` | Text shown when no value is selected. Defaults to an empty string. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Select.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the content into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. | | `children` | `React.ReactNode` | The content part. | ### Select.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `"top" \| "bottom" \| "left" \| "right"` | Preferred side to anchor the content against the trigger; flips automatically on collision. Defaults to "bottom". | | `sideOffset` | `number` | Gap in pixels between the trigger and the content along the placement axis. Defaults to 0. | | `alignOffset` | `number` | Offset in pixels along the cross axis from the aligned edge. Defaults to 0. | | `collisionPadding` | `number` | Minimum distance in pixels to keep from the viewport edges when repositioning. Defaults to 8. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, under asChild as well. | | `forceMount` | `boolean` | Keeps the content — and therefore the item registry — mounted while closed and through exit motion. | | `asChild` | `boolean` | Render onto the single child element instead of the frame the part renders. createPopperEntranceRecipe fits either path; supply a canvasgroup here if you want the whole subtree to fade as one layer. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any other outside interaction, before dismissal. | | `children` | `React.ReactNode` | The list surface contents. | ### Select.Item | Prop | Type | Description | | --- | --- | --- | | `value` (required) | `string` | The value this item selects when activated. | | `textValue` | `string` | Text Select.Value shows when this item is selected. Defaults to value. Resolves only while the item is mounted. Since 0.7.0 it does not render as the item's own label — supply that as a child. | | `disabled` | `boolean` | Prevents selection and removes the item from value resolution; a value pointing at a disabled item re-resolves to the first enabled item. Defaults to false. | | `asChild` | `boolean` | Merge item behavior (Active, activation and hover handlers, ref) onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Select.Group | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge onto the single child element instead of the frame the part renders. | | `children` | `React.ReactElement` | The grouped items (and optional label). | ### Select.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge onto the single child element instead of the textlabel the part renders. | | `children` | `React.ReactElement` | The label element to render. Required when asChild is set. | ### Select.Separator | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge onto the single child element instead of the frame the part renders. | | `children` | `React.ReactElement` | The divider element to render. Required when asChild is set. | ## Related - [Positioning with Popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Combobox > Single-value selection primitive that owns value state, input filtering, item registration, and popper positioning while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/combobox/ `@lattice-ui/react-combobox` · Stable direction · import `Combobox` · depends on `runtime`, `layer`, `motion`, `popper` Combobox is the primitive for a filterable, single-value picker: searchable selects, command palettes, and autocomplete fields. It coordinates value state, input text, query filtering, item registration, positioning, and dismissal so your component only has to render the field, the listbox, and the items. Reach for Combobox when a select needs **type-to-filter** behavior: an input narrows a registered list of items by text, the user picks one, and the chosen value drives the field. Combobox owns three pieces of state at once — the selected **value**, the **input text**, and the **open** state — and keeps them in sync, repairing a selection that no longer resolves to an enabled item so the field does not point at a value that no longer exists. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Combobox } from "@lattice-ui/react-combobox"; ``` ## Anatomy Compose the parts you need. `Root`, `Portal`, `Content`, and `Item` form the working picker. Use either `Input` (type-to-filter) or `Trigger` + `Value` (toggle and display) as the anchor, and `Group`, `Label`, and `Separator` to structure longer lists. ```tsx title="Combobox anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Combobox.Root` | yes | Owns value, input, and open state, plus the item registry and filter function. | | `Combobox.Input` | no | A `TextBox` that opens the list on focus and drives the query as the user types. Acts as the anchor. | | `Combobox.Trigger` | no | A button that toggles the list and acts as the anchor when no input is present. | | `Combobox.Value` | no | A label that displays the selected item's text (or a placeholder). | | `Combobox.Portal` | yes | Renders the listbox into a `ScreenGui` outside the local tree. | | `Combobox.Content` | yes | The positioned, dismissable, motion-driven listbox. | | `Combobox.Item` | yes | A selectable option that registers its value and text, and hides itself when filtered out. | | `Combobox.Group` | no | A container that visually groups related items. | | `Combobox.Label` | no | A non-interactive heading for a group or section. | | `Combobox.Separator` | no | A thin divider between items or groups. | ## Examples ### Basic filtered list An uncontrolled root, an input textbox as the anchor, and items on a plain list surface. Typing narrows the list in place — each item hides itself when it stops matching — and selecting an item sets the value, fills the field, and closes the list. Since 0.7.0 `textValue` no longer renders as the item's label, so each item is given a `Text` of its own here. `textValue` still drives filtering and the resolved value label; it defaults to `value`. ```tsx title="BiomePicker.tsx" import { Combobox } from "@lattice-ui/react-combobox"; const BIOMES = ["Ashlands", "Frostpeak", "Gladewood", "Mirelow", "Sunspire"]; export function BiomePicker() { return ( print(`picked: ${value}`)}> {BIOMES.map((biome) => ( ))} ); } ``` ### Controlled value and input Control both trios when something outside the combobox needs to read or drive them — persisting the selection, reporting the live query to a search service, or resetting the field from elsewhere. The two pieces are independent: `value`/`onValueChange` tracks the committed selection, while `inputValue`/`onInputValueChange` tracks the field text. Expect `onInputValueChange` to fire for programmatic syncs too — selecting an item sets the input to that item's text, and closing the list re-syncs it from the value. ```tsx title="QuestSearch.tsx" import { useState } from "@rbxts/react"; import { Combobox } from "@lattice-ui/react-combobox"; const QUESTS = ["Cinder Trial", "Echo Vault", "Gale Run", "Hollow March"]; export function QuestSearch() { const [value, setValue] = useState(); const [inputValue, setInputValue] = useState(""); return ( {QUESTS.map((quest) => ( ))} ); } ``` > **Uncontrolled usage** > > Omit `value`/`onValueChange` (and `inputValue`/`open`) and pass `defaultValue`, `defaultInputValue`, or `defaultOpen` instead to let Combobox own its state. Each of value, input text, and open state can be controlled independently — control only the pieces something outside the combobox needs to drive. ### Custom filter function The root's `filterFn` decides whether each item matches the current query. The default is a case-insensitive substring match; swap it for prefix matching, token search, or locale-aware comparison. Define the function at module scope (or memoize it) so its identity stays stable — it is shared through context, and a new function every render churns every item. ```tsx title="CommandPalette.tsx" import { Combobox } from "@lattice-ui/react-combobox"; import type { ComboboxFilterFn } from "@lattice-ui/react-combobox"; const COMMANDS = ["Teleport", "Trade", "Track quest", "Toggle HUD"]; const prefixFilter: ComboboxFilterFn = (itemText, query) => { return string.sub(string.lower(itemText), 1, query.size()) === string.lower(query); }; export function CommandPalette() { return ( print(`run: ${command}`)}> {COMMANDS.map((command) => ( ))} ); } ``` ### Groups and labels `Group`, `Label`, and `Separator` structure longer lists. They are purely visual: they do not affect filtering or selection, and a label is not query-aware — it stays visible even when every item under it is filtered out. All three render unstyled — `Group` is a bare frame with no layout or sizing of its own, so give it a layout and, for a growing list, `AutomaticSize`. ```tsx title="ServerRegionPicker.tsx" import { Combobox } from "@lattice-ui/react-combobox"; const AMERICAS = ["Chicago", "Dallas", "Sao Paulo"]; const EUROPE = ["Frankfurt", "London", "Warsaw"]; export function ServerRegionPicker() { return ( {AMERICAS.map((region) => ( ))} {EUROPE.map((region) => ( ))} ); } ``` ### Empty state when nothing matches Filtering hides items one by one — the listbox itself stays open and mounted even when every item is filtered out, and there is no built-in empty-state part. To show a "no matches" message, control `inputValue` and mirror the primitive's filtering with the exported `filterComboboxOptions` helper (it applies `defaultComboboxFilter` unless you pass the same custom `filterFn` you gave the root). When the match count hits zero, render your own label inside the content. ```tsx title="SpellSearch.tsx" import { useState } from "@rbxts/react"; import { Combobox, filterComboboxOptions } from "@lattice-ui/react-combobox"; const SPELLS = ["Arc Nova", "Ember Coil", "Frost Lattice", "Stone Ward"]; const SPELL_OPTIONS = SPELLS.map((spell) => ({ value: spell, disabled: false, textValue: spell })); export function SpellSearch() { const [inputValue, setInputValue] = useState(""); const matchCount = filterComboboxOptions(SPELL_OPTIONS, inputValue).size(); return ( {SPELLS.map((spell) => ( ))} {matchCount === 0 && ( )} ); } ``` ### Custom input styling with asChild Use `asChild` on `Combobox.Input` to project the input behavior onto your own `textbox`. The primitive drives `Text`, `TextEditable`, `PlaceholderText`, `ClearTextOnFocus`, the text-change handler, and the anchor ref onto your element, so keep the `placeholder` prop on the `Combobox.Input` part (the slot's value wins) and put your styling in colors, strokes, corners, and padding. ```tsx title="StyledCosmeticSearch.tsx" import { Combobox } from "@lattice-ui/react-combobox"; const COSMETICS = ["Aurora Cape", "Drift Visor", "Ember Trail", "Void Crown"]; export function StyledCosmeticSearch() { return ( {COSMETICS.map((cosmetic) => ( ))} ); } ``` ## How it behaves ### Open state `Combobox.Root` is controllable. Pass `open` and `onOpenChange` to control the listbox, or `defaultOpen` to run uncontrolled (defaults to closed). `Combobox.Trigger` toggles the list on activation (and on `Return`/`Space`). `Combobox.Input` opens the list when it gains focus — with an empty query every item matches, so the user sees the full set and then types to narrow it — and typing keeps it open. Selecting an item closes the list. A disabled root refuses to open, but can still close. ### Value and input state The selected **value** and the **input text** are separate, independently controllable pieces of state. Selecting an item sets the value and syncs the input to that item's display text; selecting a disabled item is ignored. Typing in the input updates the query (and opens the list) without changing the value until a selection is made; when the list closes, the input is re-synced from the current value so the field always shows the selected item. While the list is open, Combobox reconciles the value against the item registry: a selected value that no longer resolves to an enabled registered item is replaced with the first enabled item. This repair only replaces an invalid selection — it never fills an empty one, so an untouched combobox stays empty until the user picks something. `onValueChange` fires only for defined values. `Combobox.Value` displays the selected item's text, resolved from the item registry, or its `placeholder` when nothing is selected. `disabled` blocks all state changes; `readOnly` blocks input edits but still allows selection through items. ### Filtering Filtering is per-item, not per-list. `Combobox.Item` declares a `value` and an optional `textValue` (the text matched and displayed; defaults to `value`), and each item evaluates the root's `filterFn(textValue, query)` itself: a non-matching item sets its own `Visible` to false and becomes non-interactive, both for the button the part renders and for your element under `asChild`. The content never removes or reorders nodes, so your layout simply collapses around hidden items. The active query is the text the user typed. Selecting an item syncs the field text without changing the open list's query, so the list does not collapse to the selected item at the moment of selection; when the list closes, the query is re-synced from the field text, so reopening from the trigger shows the list filtered by the selected item's text. The default filter is a case-insensitive plain substring match. The package also exports the pieces as plain functions — `defaultComboboxFilter`, `filterComboboxOptions`, and `resolveComboboxInputValue` — so you can mirror the primitive's filtering in your own logic, such as an empty-state check or an external result count. ### Positioning `Combobox.Content` is positioned by the popper foundation against the active anchor — the input when present, otherwise the trigger. It flips to the opposite side on collision. Tune it with `placement` (`"top" | "bottom" | "left" | "right"`, default `"bottom"`), `sideOffset` (gap from the anchor, default `0`), `alignOffset` (shift along the cross axis, default `0`), and `collisionPadding` (minimum distance from the screen edge, default `8`). ### Dismissal `Combobox.Content` participates in dismissable-layer behavior and is always **non-modal**, so the rest of the UI stays interactive while the list is open. The trigger and input are registered as inside refs, so interacting with them does not dismiss the list. Any other outside press closes it. Use `onPointerDownOutside` and `onInteractOutside` to observe or veto those interactions before the list closes. ### Motion and presence `Combobox.Content` runs no motion of its own. Pass a `transition` to animate it — `createPopperEntranceRecipe(placement)` matches the `frame` the content renders and animates from the resolved placement. `forceMount` keeps the content mounted through its exit (useful when you drive motion yourself or need the node to persist). > **Three states, kept in sync** > > Combobox tracks value, input text, and open state separately and reconciles them automatically: selecting syncs the input, closing re-syncs the input from the value, and an invalid selection is repaired against the registry while the list is open. Control each piece only when you need to — mixing controlled value with uncontrolled input is fully supported. > **Items hide themselves — there is no built-in empty state** > > Filtering works by each item toggling its own visibility, so the listbox stays open and mounted even when nothing matches. Detect zero matches yourself with `filterComboboxOptions` over your own option data (passing your custom `filterFn` if you use one) and render your own empty label, as in the [empty state example](#empty-state-when-nothing-matches). > **required is wiring-only** > > `required` is exposed on context for your own form/validation wiring and does not change interaction on its own. The open-list value repair runs regardless of `required`, and it never fills an empty value — if you need a selection before submit, validate `value !== undefined` in your form logic. ## API reference ### Combobox.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled selected value. Pair with onValueChange. | | `defaultValue` | `string` | Initial selected value for uncontrolled usage. | | `onValueChange` | `(value: string) => void` | Called whenever the selected value changes. Fires only for defined values. | | `inputValue` | `string` | Controlled input text. Pair with onInputValueChange. | | `defaultInputValue` | `string` | Initial input text for uncontrolled usage. Defaults to an empty string. | | `onInputValueChange` | `(inputValue: string) => void` | Called whenever the input text changes, including programmatic syncs from selection and close. | | `open` | `boolean` | Controlled open state of the listbox. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes. | | `disabled` | `boolean` | Disables the whole combobox, blocking opening, input edits, and selection. Defaults to false. | | `readOnly` | `boolean` | Blocks input edits while still allowing selection through items. Defaults to false. | | `required` | `boolean` | Marks the combobox as required for your own form/validation wiring. Exposed on context; does not change interaction on its own. Defaults to false. | | `filterFn` | `(itemText: string, query: string) => boolean` | Decides whether an item matches the query. Defaults to a case-insensitive substring match. Keep its identity stable. | | `children` | `React.ReactNode` | The combobox parts. | ### Combobox.Input | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge input behavior onto the single child element instead of the textbox the part renders. The child must be a textbox. | | `disabled` | `boolean` | Disables the input. Combined with the root's disabled state. | | `readOnly` | `boolean` | Blocks edits to the input text. Combined with the root's readOnly state. | | `placeholder` | `string` | Placeholder text shown when the input is empty. Defaults to "Type to filter". | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Combobox.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge trigger behavior onto the single child element instead of the textbutton the part renders. | | `disabled` | `boolean` | Prevents the trigger from toggling the list. Combined with the root's disabled state. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Combobox.Value | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the resolved value onto the single child element instead of the textlabel the part renders. | | `placeholder` | `string` | Text shown when no value is selected. Defaults to an empty string. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Combobox.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the listbox into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. | | `children` | `React.ReactNode` | The content part. | ### Combobox.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the listbox on. Flips on collision. Defaults to "bottom". | | `sideOffset` | `number` | Gap in pixels between the anchor and the listbox. Defaults to 0. | | `alignOffset` | `number` | Shift in pixels along the anchor's cross axis. Defaults to 0. | | `collisionPadding` | `number` | Minimum distance in pixels to keep from the screen edge. Defaults to 8. | | `asChild` | `boolean` | Render the single child element inside the positioned wrapper instead of the frame the part renders. | | `forceMount` | `boolean` | Keeps the listbox mounted while exit motion runs. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the listbox, before dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any other outside interaction, before dismissal. | | `children` | `React.ReactNode` | The listbox contents. | ### Combobox.Item | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Required. The value selected when this item is chosen. | | `textValue` | `string` | Text used for filtering and for the resolved value label. Defaults to value. Since 0.7.0 it does not render as the item's own label — supply that as a child or via a forwarded Text prop. | | `disabled` | `boolean` | Prevents selection and excludes the item from open-list value repair. | | `asChild` | `boolean` | Merge item behavior onto the single child element instead of the textbutton the part renders. The child's Visible property is bound to the filter match. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Combobox.Group | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the group onto the single child element instead of the frame the part renders. | | `children` | `React.ReactElement` | The grouped items to render. Required when asChild is set. | ### Combobox.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the label onto the single child element instead of the textlabel the part renders. | | `children` | `React.ReactElement` | The label element to render. Required when asChild is set. | ### Combobox.Separator | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the separator onto the single child element instead of the frame the part renders. | | `children` | `React.ReactElement` | The divider element to render. Required when asChild is set. | ## Related - [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Text Field > Single-line text input primitive that owns the value, separates live changes from commit, exposes disabled/readOnly/invalid state, and wires label, description, and message parts together. Source: https://docs.astra-void.xyz/lattice-ui/components/text-field/ `@lattice-ui/react-text-field` · Stable direction · import `TextField` · depends on `runtime`, `focus`, `motion` Text Field is the primitive for any single-line input: a username box, a search bar, a price entry, a private-server name. It wraps a Roblox `TextBox`, owns the current value, and distinguishes a live change (every keystroke) from a commit (when editing ends), so your component decides when to validate or persist. Reach for Text Field when an input needs **controlled or uncontrolled value state**, a clear split between **change and commit**, and shared **disabled / readOnly / required / invalid** state that flows to a label, helper description, and validation message. For multi-line input with auto-resize, [Textarea](https://docs.astra-void.xyz/lattice-ui/components/textarea.md) is the sibling primitive. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { TextField } from "@lattice-ui/react-text-field"; ``` ## Anatomy Compose `Root` around an `Input`, plus any of the optional text parts. Only `Root` and `Input` are required; `Label`, `Description`, and `Message` read shared state from context. ```tsx title="TextField anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `TextField.Root` | yes | Owns the value, the commit callback, and shared disabled/readOnly/required/invalid state. | | `TextField.Input` | yes | The `TextBox` that renders the value and reports changes, focus, and commit. | | `TextField.Label` | no | A `textbutton` that focuses the input when activated. | | `TextField.Description` | no | A static `textlabel` for helper text. | | `TextField.Message` | no | A `textlabel` for validation text that recolors when `invalid` is set. | ## Examples ### Basic labeled field An uncontrolled field seeded with `defaultValue`: the Root keeps the value internally and you only hear about it through the callbacks. `Label`, `Input`, and `Description` each take `asChild` to project their behavior onto your own elements — clicking the label captures focus on the input, and the description is plain helper text that dims with the field. ```tsx title="DisplayNameField.tsx" import { TextField } from "@lattice-ui/react-text-field"; export function DisplayNameField() { return ( print(`display name: ${value}`)}> ); } ``` ### Controlled value Pass `value` and `onValueChange` when something outside the field needs to read or set the text — filtering a list as the player types, or resetting the field from a button. Because the owned value is mirrored onto the `TextBox`, setting state from outside (like the clear button here) updates the visible text too. ```tsx title="ItemSearchField.tsx" import { useState } from "@rbxts/react"; import { TextField } from "@lattice-ui/react-text-field"; export function ItemSearchField(props: { onQueryChange: (query: string) => void }) { const [query, setQuery] = useState(""); const updateQuery = (nextQuery: string) => { setQuery(nextQuery); props.onQueryChange(nextQuery); }; return ( updateQuery("") }} Size={UDim2.fromOffset(52, 36)} Text="Clear" TextColor3={Color3.fromRGB(236, 241, 249)} /> ); } ``` ### Change vs commit `onValueChange` fires on every text change; `onValueCommit` fires once when editing ends — the `TextBox` loses focus because the player pressed Enter or clicked away. Use change for live UI (previews, counters, filtering) and commit for the expensive or one-shot work: saving to the server, running validation, recording history. Here the draft updates live while the save only happens on commit. ```tsx title="ServerNameField.tsx" import { useState } from "@rbxts/react"; import { TextField } from "@lattice-ui/react-text-field"; export function ServerNameField(props: { onSave: (name: string) => void }) { const [draft, setDraft] = useState("My Server"); const [saved, setSaved] = useState("My Server"); return ( { setSaved(committed); props.onSave(committed); }} > ); } ``` ### Validation with invalid and Message Validate on commit rather than every keystroke, then flip `invalid` on the Root. `invalid` is a shared flag: the `Message` part reads it from context and recolors its text to the error tone, and your own elements can branch on the same state. Swapping `Message` for `Description` keeps the layout height stable while switching between helper and error copy. ```tsx title="UsernameField.tsx" import { useState } from "@rbxts/react"; import { TextField } from "@lattice-ui/react-text-field"; export function UsernameField() { const [name, setName] = useState(""); const [error, setError] = useState(false); return ( setError(committed.size() < 3)} invalid={error} required name="username" > {error ? ( ) : ( )} ); } ``` ### Disabled and readOnly Both flags freeze the value, but they differ in interaction. `disabled` takes the input out of play entirely — it clears `Active`/`Selectable`, dims the text, ignores edits, and suppresses the commit callback. `readOnly` keeps the field focusable and selectable (players can still click into it and copy the text) but rejects edits; a focus loss still fires `onValueCommit` with the unchanged text. Here the join code is copyable but locked, while the region field is fully inert until unlocked. ```tsx title="ServerInfoFields.tsx" import { TextField } from "@lattice-ui/react-text-field"; export function ServerInfoFields(props: { joinCode: string; canEditRegion: boolean }) { return ( ); } ``` ### Filtered input For numeric-only entry, control the value and strip rejected characters in `onValueChange` before they reach state. The `TextBox` text is bound to the owned value, so the field tracks the filtered result. Run the same normalization at commit — `onValueCommit` receives the box's final text, so it is the place to parse and clamp the settled value. ```tsx title="BetAmountField.tsx" import { useState } from "@rbxts/react"; import { TextField } from "@lattice-ui/react-text-field"; const MAX_BET = 500; export function BetAmountField(props: { onBetChange: (amount: number) => void }) { const [amount, setAmount] = useState("0"); return ( { const [digits] = text.gsub("%D", ""); setAmount(digits); }} onValueCommit={(text) => { const [digits] = text.gsub("%D", ""); const clamped = math.clamp(tonumber(digits) ?? 0, 0, MAX_BET); setAmount(tostring(clamped)); props.onBetChange(clamped); }} > ); } ``` ## How it behaves ### Value state `TextField.Root` is controllable. Pass `value` and `onValueChange` to control it, or `defaultValue` to run uncontrolled; when neither is set the value starts empty. The owned value is bound to the `TextBox`'s `Text`, so the displayed text tracks the state rather than whatever Roblox last typed. Setting the same value again is a no-op — `onValueChange` only fires when the text actually differs from the current value. ### Change and commit A keystroke fires the `TextBox` text change, which calls `onValueChange` with the new text. A commit happens on `FocusLost` — when the player presses Enter, clicks away, or otherwise ends editing — and calls `onValueCommit` with the text as it stands in the box at that moment. Commit is not a diff: it fires on every focus loss, including when the text is unchanged, and it is suppressed only while `disabled` (a `readOnly` field still commits its unchanged text). Use `onValueChange` for live updates and `onValueCommit` for validation or persistence you only want once editing settles. ### Disabled and readOnly `disabled` and `readOnly` both stop edits from updating the value: while either is set, `Root.setValue` ignores incoming text and the `Input` rewrites the `TextBox` back to the owned value, so stray platform input cannot desync the field. They differ in interaction — `disabled` also clears `Active`/`Selectable`, makes the text non-editable, and dims the input text, while `readOnly` only drops `TextEditable`, keeping the field selectable and focusable. A disabled field additionally suppresses the commit callback on focus loss. `Input` can also set `disabled`/`readOnly` locally, which combine with `Root`'s state via OR — useful for locking one input inside an otherwise live field. ### Required, invalid, and name `required` and `invalid` are shared flags that carry no enforcement on their own — `required` is exposed on context for your own validation and submission logic, and `invalid` is a visual/semantic marker. When `invalid` is set, `TextField.Message` recolors its text to the error tone (`RGB(255, 128, 128)`); `Description` never recolors for validity. The `name` prop is passed through context for form identification and is otherwise inert. ### Label and focus `TextField.Label` renders a `textbutton`; activating it calls `CaptureFocus()` on the input's `TextBox`, so clicking the label focuses the field. While the field is disabled the label drops its `Active`/`Selectable` state, dims, and does nothing on activation. `Description` and `Message` are non-interactive labels that read shared state for their text color and dim alongside a disabled field. ### Input focus motion The `Input` tracks focus and exposes it through context, but no longer paints it. It used to animate its `BackgroundColor3` toward a focused accent with the field response recipe; as of 0.7.0 the primitive writes no color, in either mode. Focus while `disabled` or `readOnly` still does not count as active, so a read-only field should not light up as editable — branch on the state yourself, and add a `createFieldResponseRecipe()` response motion if you want the change to ease. > **Default text and visuals** > > Every part renders unstyled, and none of them carry copy. `Input` is a `textbox` with `ClearTextOnFocus` off and no size or placeholder of its own; `Label`, `Description`, and `Message` used to render the literal text `"Label"`, `"Description"`, and `"Message"`, and as of 0.7.0 render nothing until you supply it. > > Pass the copy and styling as props — they forward onto the instance each part renders — or use `asChild` when you need a different element class. All four also render children now, so a `uipadding` or `uicorner` attaches directly. > **Commit fires on every focus loss** > > `onValueCommit` is not change detection — it fires whenever editing ends, even if the text is identical to the last commit, and `readOnly` does not suppress it (only `disabled` does). If you persist on commit, dedupe against the last saved value yourself, as in the change-vs-commit example. > **Input children must be a TextBox** > > With `asChild`, the `Input` merges its `Text` binding, editability flags, and `Focused`/`FocusLost`/text-change handlers onto your single child, and its ref wiring only accepts instances that are a `TextBox`. Use a `textbox` element; a `textlabel` or `frame` will not report changes or focus. ## API reference ### TextField.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled value. Pair with onValueChange. | | `defaultValue` | `string` | Initial value for uncontrolled usage. Defaults to an empty string. | | `onValueChange` | `(value: string) => void` | Called on every text change while the field is editable. Not called when the new text equals the current value. | | `onValueCommit` | `(value: string) => void` | Called with the box's final text when editing ends (focus lost via Enter or clicking away). Fires even when the text is unchanged; suppressed while disabled. | | `disabled` | `boolean` | Blocks edits, clears Active/Selectable, dims the input, and suppresses commit. Defaults to false. | | `readOnly` | `boolean` | Blocks edits while keeping the field selectable and focusable; commit still fires. Defaults to false. | | `required` | `boolean` | Shared flag exposed on context for your own validation wiring; not enforced. Defaults to false. | | `invalid` | `boolean` | Marks the field invalid and recolors the Message part to the error tone. Defaults to false. | | `name` | `string` | Identifier passed through context for form usage. No behavior of its own. | | `children` | `React.ReactNode` | The field parts. | ### TextField.Input | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge input behavior onto the single child element instead of the textbox the part renders. The child must be a textbox. | | `disabled` | `boolean` | Local disabled state, combined with Root's via OR. Defaults to false. | | `readOnly` | `boolean` | Local readOnly state, combined with Root's via OR. Defaults to false. | | `children` | `React.ReactElement` | The textbox element to render. Required when asChild is set. | ### TextField.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge label behavior (focus-on-activate, disabled state) onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### TextField.Description | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the description onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### TextField.Message | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the message onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Textarea > Multi-line text input primitive that owns the value, separates change from commit, auto-resizes to its content within row bounds, and wires label, description, and message parts together. Source: https://docs.astra-void.xyz/lattice-ui/components/textarea/ `@lattice-ui/react-textarea` · Stable direction · import `Textarea` · depends on `runtime`, `focus`, `motion` Textarea is the primitive for multi-line input: a report description, a tribe message, feedback, or any field where text spans several lines. It wraps a multi-line Roblox `TextBox`, owns the value, splits live changes from commit, and grows its height to fit the content between configurable row bounds. Reach for Textarea when an input needs **controlled or uncontrolled value state**, a clean **change-versus-commit** split, **auto-resizing** that clamps between a minimum and maximum number of rows, and shared **disabled / readOnly / required / invalid** state across a label, description, and message. For single-line input without the height machinery, [Text Field](https://docs.astra-void.xyz/lattice-ui/components/text-field.md) is the sibling primitive. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Textarea } from "@lattice-ui/react-textarea"; ``` The package also exports the pure height helper used internally: ```ts import { resolveTextareaHeight } from "@lattice-ui/react-textarea"; ``` ## Anatomy Compose `Root` around an `Input`, plus any of the optional text parts. Only `Root` and `Input` are required; `Label`, `Description`, and `Message` read shared state from context. ```tsx title="Textarea anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Textarea.Root` | yes | Owns the value, commit callback, shared state, and the auto-resize bounds. | | `Textarea.Input` | yes | The multi-line `TextBox` that renders the value, reports changes, and resizes to fit. | | `Textarea.Label` | no | A `textbutton` that focuses the input when activated. | | `Textarea.Description` | no | A static `textlabel` for helper text. | | `Textarea.Message` | no | A `textlabel` for validation text that recolors when `invalid` is set. | ## Examples ### Basic labeled textarea An uncontrolled field seeded with `defaultValue`: the Root keeps the value internally and you only hear about it through the callbacks. `Label`, `Input`, and `Description` each take `asChild` to project their behavior onto your own elements — clicking the label captures focus on the input, and the description dims alongside a disabled field. Auto-resize is on by default, so the input's height is driven for you; the `Size` you pass supplies the width and an initial height. ```tsx title="FeedbackField.tsx" import { Textarea } from "@lattice-ui/react-textarea"; export function FeedbackField() { return ( print(`feedback: ${value}`)}> ); } ``` ### Auto-resize with row bounds `autoResize` is on by default; `minRows` and `maxRows` set the bounds. Here a note composer starts at two rows and grows as the player types — from wrapped lines as well as explicit newlines — until it hits six rows, after which the height stops growing. The primitive only rewrites the height, keeping your width, so give the input a fixed pixel width and let the field own the rest. ```tsx title="NoteComposer.tsx" import { Textarea } from "@lattice-ui/react-textarea"; export function NoteComposer() { return ( ); } ``` Put the input in a layout that tolerates height changes — a `uilistlayout` column reflows siblings automatically. If you need the numbers without mounting anything (reserving space in a fixed layout, sizing a sibling), call `resolveTextareaHeight` with the same options. ### Change vs commit `onValueChange` fires on every text change; `onValueCommit` fires once when editing ends and the `TextBox` loses focus. Use change for live UI — previews, counters, dirty indicators — and commit for the expensive or one-shot work: saving to the server, running validation, recording history. Here the draft updates live while the save only happens on commit. ```tsx title="ClanDescriptionField.tsx" import { useState } from "@rbxts/react"; import { Textarea } from "@lattice-ui/react-textarea"; export function ClanDescriptionField(props: { onSave: (description: string) => void }) { const [draft, setDraft] = useState("A clan for casual raids."); const [saved, setSaved] = useState("A clan for casual raids."); return ( { setSaved(committed); props.onSave(committed); }} minRows={2} maxRows={5} > ); } ``` ### Validation with invalid and Message Validate on commit rather than every keystroke, then flip `invalid` on the Root. `invalid` is a shared flag: the `Message` part reads it from context and recolors its text to the error tone, and your own elements can branch on the same state. Swapping `Message` for `Description` keeps the layout height stable while switching between helper and error copy. ```tsx title="ReportField.tsx" import { useState } from "@rbxts/react"; import { Textarea } from "@lattice-ui/react-textarea"; export function ReportField() { const [text, setText] = useState(""); const [error, setError] = useState(false); return ( setError(committed.size() === 0)} invalid={error} required minRows={3} maxRows={8} name="report" > {error ? ( ) : ( )} ); } ``` ### Character counter For a length-limited field, control the value and truncate in `onValueChange` before the text reaches state. The `TextBox` text is bound to the owned value, so anything past the limit never shows up in the box, and the counter derives straight from the same state — no second source of truth. The counter recolors as the player approaches the cap. ```tsx title="BioField.tsx" import { useState } from "@rbxts/react"; import { Textarea } from "@lattice-ui/react-textarea"; const MAX_BIO = 200; export function BioField() { const [bio, setBio] = useState(""); return ( setBio(text.sub(1, MAX_BIO))} onValueCommit={(committed) => print(`bio saved: ${committed}`)} minRows={3} maxRows={6} name="bio" > = MAX_BIO ? Color3.fromRGB(255, 128, 128) : Color3.fromRGB(170, 179, 195)} TextXAlignment={Enum.TextXAlignment.Right} /> ); } ``` ## How it behaves ### Value state `Textarea.Root` is controllable. Pass `value` and `onValueChange` to control it, or `defaultValue` to run uncontrolled; when neither is set the value starts empty. The owned value is bound to the `TextBox`'s `Text`, so the displayed text tracks the state rather than whatever Roblox last typed. Setting the same value again is a no-op — `onValueChange` only fires when the text actually differs from the current value. The underlying `TextBox` renders with `MultiLine` and `TextWrapped` enabled, top-aligned text, and `ClearTextOnFocus` off. ### Change and commit A keystroke fires the `TextBox` text change, which calls `onValueChange` with the new text. A commit happens on `FocusLost` — when the player ends editing by clicking away or moving focus — and calls `onValueCommit` with the text as it stands in the box at that moment. Commit is not a diff: it fires on every focus loss, including when the text is unchanged, and it is suppressed only while `disabled` (a `readOnly` field still commits its unchanged text). Use `onValueChange` for live updates and `onValueCommit` for validation or persistence you only want once editing settles. ### Auto-resize `autoResize` defaults to `true`. After each text change — and whenever the value changes externally — the `Input` measures its content and sets its height to `rows × lineHeight + verticalPadding`: - **Rows** is the larger of the newline count and the wrapped-text measurement (`TextBounds.Y` divided by the line height, rounded up), floored at 1, then clamped between the bounds: `minRows` defaults to `3` (floored at 1), and `maxRows`, when set, is raised to at least `minRows`. Past `maxRows` the field stops growing and keeps that fixed height. - **Line height** defaults to `ceil(TextSize × 1.2)` unless you pass an explicit `lineHeight` on `Input`. - **Vertical padding** is summed from the input's `UIPadding` children (offsets plus scale resolved against the box's absolute height), falling back to `14` when none contribute. Each measurement also re-runs on a deferred frame so wrapped `TextBounds` that settle after the change are picked up. The resize writes `UDim2.fromOffset(currentWidth, height)` — it preserves your X offset but replaces any scale-based sizing, so size the input with pixel offsets when auto-resize is on. Disabled and read-only inputs still re-measure, so an externally updated value keeps the height correct. Set `autoResize={false}` to keep a fixed height and size the input yourself. The height math is exposed as the pure function `resolveTextareaHeight(text, options)` — the same clamping given `minRows`, `maxRows`, `lineHeight`, and optional `verticalPadding`/`measuredRows` — if you need to compute a layout without mounting the component. ### Disabled and readOnly `disabled` and `readOnly` both stop edits from updating the value: while either is set, `Root.setValue` ignores incoming text and the `Input` rewrites the `TextBox` back to the owned value (still re-running auto-resize), so stray platform input cannot desync the field. They differ in interaction — `disabled` also clears `Active`/`Selectable`, makes the text non-editable, dims the input text, and suppresses the commit callback on focus loss, while `readOnly` only drops `TextEditable`, keeping the field selectable and focusable. `Input` can also set `disabled`/`readOnly` locally, which combine with `Root`'s state via OR. ### Required, invalid, and name `required` and `invalid` are shared flags that carry no enforcement on their own — `required` is exposed on context for your own validation and submission logic, and `invalid` is a visual/semantic marker. When `invalid` is set, `Textarea.Message` recolors its text to the error tone (`RGB(255, 128, 128)`); `Description` never recolors for validity. The `name` prop is passed through context for form identification and is otherwise inert. ### Label and focus `Textarea.Label` renders a `textbutton`; activating it calls `CaptureFocus()` on the input's `TextBox`, so clicking the label focuses the field. While the field is disabled the label drops its `Active`/`Selectable` state, dims, and does nothing on activation. `Description` and `Message` are non-interactive labels that read shared state for their text color and dim alongside a disabled field. ### Input focus motion The `Input` tracks focus and exposes it through context, but no longer paints it. It used to animate its `BackgroundColor3` toward a focused accent with the field response recipe; as of 0.7.0 the primitive writes no color, in either mode. Focus while `disabled` or `readOnly` still does not count as active, so a read-only field should not light up as editable — branch on the state yourself, and add a `createFieldResponseRecipe()` response motion if you want the change to ease. > **Default text and visuals** > > Every part renders unstyled, and none of them carry copy. `Input` is a multi-line `textbox` with no size, placeholder, or padding of its own; `Label`, `Description`, and `Message` used to render the literal text `"Label"`, `"Description"`, and `"Message"`, and as of 0.7.0 render nothing until you supply it. > > Pass the copy and styling as props — they forward onto the instance each part renders — or use `asChild` when you need a different element class. The auto-resize and state wiring are unaffected either way, and all four parts render children, so a `uipadding` attaches directly. > **Auto-resize owns the height** > > With `autoResize` on (the default), the primitive rewrites the input's `Size` to an offset height on mount and after every change, keeping only your X offset. Size the input's width in pixels, treat the height you pass as an initial value, and put the field in a layout that reflows — or set `autoResize={false}` and own the height yourself. > **Commit fires on every focus loss** > > `onValueCommit` is not change detection — it fires whenever editing ends, even if the text is identical to the last commit, and `readOnly` does not suppress it (only `disabled` does). If you persist on commit, dedupe against the last saved value yourself, as in the change-vs-commit example. > **Input children must be a TextBox** > > With `asChild`, the `Input` merges its `Text` binding, `MultiLine`/`TextWrapped` flags, editability, and `Focused`/`FocusLost`/text-change handlers onto your single child, and its ref wiring only accepts instances that are a `TextBox`. Use a `textbox` element; a `textlabel` or `frame` will not report changes, focus, or auto-resize. ## API reference ### Textarea.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled value. Pair with onValueChange. | | `defaultValue` | `string` | Initial value for uncontrolled usage. Defaults to an empty string. | | `onValueChange` | `(value: string) => void` | Called on every text change while the field is editable. Not called when the new text equals the current value. | | `onValueCommit` | `(value: string) => void` | Called with the box's final text when editing ends (focus lost). Fires even when the text is unchanged; suppressed while disabled. | | `disabled` | `boolean` | Blocks edits, clears Active/Selectable, dims the input, and suppresses commit. Defaults to false. | | `readOnly` | `boolean` | Blocks edits while keeping the field selectable and focusable; commit still fires. Defaults to false. | | `required` | `boolean` | Shared flag exposed on context for your own validation wiring; not enforced. Defaults to false. | | `invalid` | `boolean` | Marks the field invalid and recolors the Message part to the error tone. Defaults to false. | | `name` | `string` | Identifier passed through context for form usage. No behavior of its own. | | `autoResize` | `boolean` | Grows the input height to fit its content within the row bounds. Defaults to true. | | `minRows` | `number` | Minimum visible rows. Floored at 1. Defaults to 3. | | `maxRows` | `number` | Maximum visible rows before the height stops growing. Raised to at least minRows when set. Unbounded by default. | | `children` | `React.ReactNode` | The field parts. | ### Textarea.Input | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge input behavior onto the single child element instead of rendering the default multi-line textbox. The child must be a textbox. | | `disabled` | `boolean` | Local disabled state, combined with Root's via OR. Defaults to false. | | `readOnly` | `boolean` | Local readOnly state, combined with Root's via OR. Defaults to false. | | `lineHeight` | `number` | Explicit per-row pixel height used by auto-resize. Defaults to ceil(TextSize × 1.2). | | `children` | `React.ReactElement` | The textbox element to render. Required when asChild is set. | ### Textarea.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge label behavior (focus-on-activate, disabled state) onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Textarea.Description | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the description onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Textarea.Message | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the message onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Dialog > Modal surface primitive that owns open state, focus trapping, layered dismissal, and presence motion while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/dialog/ `@lattice-ui/react-dialog` · Stable direction · import `Dialog` · depends on `runtime`, `focus`, `layer`, `motion` Dialog is the primitive for any surface that should take over the screen: confirmations, forms, settings panels, and store windows. It coordinates open state, focus, dismissal, and exit motion so your component only has to render the frame and its contents. Reach for Dialog when a surface needs to be **modal** (block interaction behind it), **restore focus** to whatever opened it, and **dismiss predictably** — by an explicit close, an outside interaction, or a controlled state change. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. {/* The dialog panel is a fixed 420px wide — the only scene on these pages that a phone's docs column would clip. `base` keeps it composed and scaled to fit. */} _Interactive preview._ ## Import ```ts import { Dialog } from "@lattice-ui/react-dialog"; ``` ## Anatomy Compose the full set of parts. `Trigger`, `Overlay`, and `Close` are optional depending on how you drive the dialog, but `Root`, `Portal`, and `Content` form the minimum useful surface. ```tsx title="Dialog anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Dialog.Root` | yes | Owns open state and shares it with every part through context. | | `Dialog.Trigger` | no | A button that opens the dialog and is the default focus-restore target. | | `Dialog.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. | | `Dialog.Overlay` | no | A full-screen backdrop behind the content that closes the dialog on press. | | `Dialog.Content` | yes | The focus-trapped, dismissable surface. | | `Dialog.Close` | no | A button that closes the dialog from inside the content. | ## Examples ### Basic usage Uncontrolled state seeded with `defaultOpen`, a trigger that opens it, a dimmed overlay, and a close button inside the content. `Dialog.Overlay` covers the screen and dismisses the dialog on press whether or not you style it — but it draws nothing until you give it a color, so pass `BackgroundColor3` and `BackgroundTransparency` for a visible dim. ```tsx title="QuestDialog.tsx" import { Dialog } from "@lattice-ui/react-dialog"; export function QuestDialog() { return ( ); } ``` ### Controlled confirmation from a game event Pass `open` and `onOpenChange` when something other than a trigger drives the dialog — a server event, a touched part, a timer. There is no `Dialog.Trigger` here, which changes focus restoration: a trigger focuses itself the moment it opens the dialog, so the focus scope has a reliable snapshot to restore. Opened from a game event, there is usually nothing meaningful focused at open time, so opt out with `restoreFocus={false}` (or focus a specific element yourself when the dialog closes). Note that an outside press still closes the dialog through `onOpenChange`, so treat `open` becoming `false` — not just the buttons — as the "dismissed" signal. ```tsx title="TradeRequestDialog.tsx" import { useEffect, useState } from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function TradeRequestDialog(props: { requestFrom?: string; onRespond: (accepted: boolean) => void; }) { const [open, setOpen] = useState(false); useEffect(() => { if (props.requestFrom !== undefined) { setOpen(true); } }, [props.requestFrom]); const respond = (accepted: boolean) => { props.onRespond(accepted); setOpen(false); }; return ( respond(true) }} Size={UDim2.fromOffset(120, 32)} Text="Accept" TextColor3={Color3.fromRGB(240, 244, 250)} /> respond(false) }} Size={UDim2.fromOffset(120, 32)} Text="Decline" TextColor3={Color3.fromRGB(240, 244, 250)} /> ); } ``` ### Custom overlay and content transition Use `asChild` on `Dialog.Overlay` when you need a different element class for the backdrop, and pass `transition` on `Dialog.Content` for the entrance. Two things to design around: the overlay runs no motion of its own and owns no color, so both the dim and any fade are yours; and it closes the dialog on press, so keep the child an activatable button class. The content's `transition` is used as-is (see [Motion and presence](#motion-and-presence)) — here a taller 24px rise. ```tsx title="StoreDialog.tsx" import { Dialog } from "@lattice-ui/react-dialog"; import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion"; const STORE_REVEAL: PresenceMotionConfig = { target: motionTargets.offsetWrapper("store reveal"), initial: { Position: UDim2.fromOffset(0, 24) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.2, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 24) }, intent: { duration: 0.16, tempo: "swift", tone: "calm" }, }, }; export function StoreDialog() { return ( ); } ``` ### Non-modal panel that vetoes dismissal `modal={false}` removes the full-screen input sink, so the game world and other UI behind the panel stay interactive. Outside presses still route to the dialog and would dismiss it — modality controls blocking, not dismissal — so a persistent panel also has to veto the close by calling `event.preventDefault()` in `onInteractOutside`. Dropping `trapFocus` lets gamepad selection leave the panel too. The result closes only through its own button. ```tsx title="CraftingPanel.tsx" import { useState } from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function CraftingPanel() { const [open, setOpen] = useState(false); return ( event.preventDefault()} > ); } ``` There is no overlay here on purpose: a backdrop would read as modal, and the default overlay would dismiss on press anyway. ### Stacked dialogs with display order Each `Dialog.Content` mounts its own layered `ScreenGui` whose `DisplayOrder` is `displayOrderBase` plus a global mount counter, so a later-opened dialog already lands above an earlier one at the same base. Explicit `displayOrderBase` bands make the ordering deterministic against your other layered UI — here the settings dialog sits in the 1000 band and its destructive confirmation in the 2000 band. Outside presses always go to the top-most open layer, so pressing the settings surface while the confirmation is up dismisses only the confirmation. ```tsx title="ResetSettingsDialog.tsx" import { useState } from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function ResetSettingsDialog() { const [settingsOpen, setSettingsOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); return ( setConfirmOpen(true) }} Size={UDim2.fromOffset(160, 34)} Text="Reset progress" TextColor3={Color3.fromRGB(240, 244, 250)} /> { setConfirmOpen(false); setSettingsOpen(false); }, }} Size={UDim2.fromOffset(100, 32)} Text="Reset" TextColor3={Color3.fromRGB(240, 244, 250)} /> ); } ``` The confirmation is a controlled sibling dialog rather than a nested `Dialog.Trigger`, so it survives its own open/close cycle without being tied to the settings content tree. ### Exit animation with forceMount Without `forceMount`, `Dialog.Content` sits inside a presence wrapper that unmounts it after the exit motion reports completion, with a short fallback window as a safety net — long, expressive exits risk being cut off by that fallback. `forceMount` bypasses the presence wrapper entirely: the content stays mounted while closed (hidden once the exit finishes), the full exit intent always plays, and child state such as scroll position or `TextBox` contents survives between opens. ```tsx title="MatchResultsDialog.tsx" import { Dialog } from "@lattice-ui/react-dialog"; import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion"; const RESULTS_TRANSITION: PresenceMotionConfig = { target: motionTargets.offsetWrapper("results reveal"), initial: { Position: UDim2.fromOffset(0, 16) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.35, tempo: "gentle", tone: "expressive" }, }, exit: { values: { Position: UDim2.fromOffset(0, 16) }, intent: { duration: 0.28, tempo: "gentle", tone: "expressive" }, }, }; export function MatchResultsDialog(props: { open: boolean; onOpenChange: (open: boolean) => void; }) { return ( ); } ``` A force-mounted dialog that has never opened renders with its exit values pre-applied, so it stays invisible until the first reveal. ## How it behaves ### Open state `Dialog.Root` is controllable. Pass `open` and `onOpenChange` to control it, or `defaultOpen` to run uncontrolled (defaulting to closed). Every path to a state change — trigger activation, close button, overlay press, outside-press dismissal — goes through the same `setOpen`, so `onOpenChange` sees all of them and controlled and uncontrolled usage behave identically. ### Focus and selection `Dialog.Content` wraps its children in a focus scope. By default it **traps focus** (`trapFocus` defaults to `true`) so gamepad and selection movement stay inside the surface while it is open, and **restores focus** (`restoreFocus` defaults to `true`) to whatever was focused just before it opened. `Dialog.Trigger` is what makes restoration reliable: it registers itself as a focus node and focuses itself in the same activation that opens the dialog, so the scope's restore snapshot points at the trigger. When you open a dialog without a trigger — controlled state driven by a game event — there may be nothing focused at open time; either pass `restoreFocus={false}` or move focus yourself after closing. ### Dismissal and layering `Dialog.Content` registers on a global dismissable-layer stack. An outside pointer press (mouse button or touch, ignoring input the engine already processed) is routed to the **top-most open layer only**: `onPointerDownOutside` fires first, then `onInteractOutside` for the same press, and then the dialog closes unless either handler called `event.preventDefault()`. This is how stacked dialogs behave sanely — a press on a lower dialog dismisses only the top one. "Outside" is measured against the first direct host element you render inside `Dialog.Content` (your panel frame); additional top-level host children also count as inside. Presses within those bounds never trigger dismissal. Under `asChild` the same rule applies one level down, against the first host child of the element you supplied. `modal` (default `true`) controls blocking, not dismissal: when modal, a full-screen input sink behind the content swallows interaction with everything underneath. With `modal={false}` the world stays interactive, but outside presses still dismiss unless you veto them. ### Motion and presence `Dialog.Content` renders your children inside a full-screen `Frame`, but runs **no motion of its own**. Pass a `transition` to animate it; the config you pass is used as-is, with nothing underneath to merge with. What the transition can move is the host — animate `Position` and the whole surface slides: ```tsx title="Opting into the reveal" import { motionTargets, type PresenceMotionConfig } from "@lattice-ui/react-motion"; const RISE: PresenceMotionConfig = { target: motionTargets.offsetWrapper("dialog rise"), initial: { Position: UDim2.fromOffset(0, 8) }, reveal: { values: { Position: UDim2.fromOffset(0, 0) }, intent: { duration: 0.12, tempo: "swift", tone: "calm" }, }, exit: { values: { Position: UDim2.fromOffset(0, 8) }, intent: { duration: 0.096, tempo: "swift", tone: "calm" }, }, }; {/* your panel */} ``` #### Fading a dialog The default host is a plain `Frame` spanning the whole layer, and a `Frame` has no property that fades its descendants — `BackgroundTransparency` on the host fades the host's own background, which covers the screen. Animating it through `transition` fills the screen with a rectangle instead of fading your panel. So either keep the content transition to `Position` and put the fade where the pixels are: - **Fade the dim.** `createOverlayFadeRecipe()` on an element inside `Dialog.Overlay` covers most of what a dialog reveal reads as. - **Fade your own elements.** Animate `BackgroundTransparency` on your panel frame and `TextTransparency` / `ImageTransparency` on the children that need it, driven by the same `open` state you pass to `Dialog.Root`. Keep those durations at or under the content transition's exit duration, or presence unmounts the tree before your fade finishes. — or hand the dialog a `canvasgroup` with `asChild`. Your element becomes the motion host, so `createCanvasGroupRevealRecipe()` fades the whole subtree as one composited layer, the way the primitive used to before it stopped rendering a `CanvasGroup` for every dialog: ```tsx title="Opting into a whole-surface fade" import { createCanvasGroupRevealRecipe } from "@lattice-ui/react-motion"; ``` That buys an offscreen render target the size of the screen, which is exactly why it is opt-in rather than the default. > **asChild moves the outside-press boundary down a level** > > Outside presses are measured against the first direct host element inside `Dialog.Content`. With `asChild` your element *is* the host and spans the layer, so the boundary becomes its first host child instead — the panel above. Keep your panel as that first child, exactly as you would without `asChild`. Presence timing is independent of that: with or without a `transition`, the content stays mounted until its exit resolves, so an exit animation is never cut off. `forceMount` on either part keeps it mounted while closed and lets long exits run outside the presence wrapper's bounded unmount window. `Dialog.Overlay` exposes no `transition` prop — an unstyled overlay has nothing to fade. It still owns presence timing. To animate a dim, render an element inside the overlay (or pass `asChild`) and animate that element yourself; `createOverlayFadeRecipe()` describes the fade the primitive used to run. > **An unstyled overlay is invisible, not absent** > > `Dialog.Overlay` renders a fully transparent full-screen `textbutton`. It still covers the screen and still swallows presses — which is how press-to-close and modal blocking work — but it draws nothing until you give it a color. Pass `BackgroundColor3` and `BackgroundTransparency` directly; it also renders children, so a `uigradient` or nested frame works too. > **Make your panel the first child of Content** > > Outside-press detection hit-tests against the first direct host element inside `Dialog.Content` — or, with `asChild`, inside the element you supplied. Render your panel frame as that first child and keep everything interactive inside it, or presses on your own surface will be treated as outside and dismiss the dialog. > **Roblox layering** > > The layered surface is a generated `ScreenGui` (with `ZIndexBehavior.Sibling`, ignoring GUI inset) rendered into `BasePlayerGui`, not the local component tree. Use `container` on `Dialog.Portal` to target a specific `PlayerGui` and `displayOrderBase` to place the layer's `DisplayOrder` band; within a band, later-opened layers stack above earlier ones automatically. ## API reference ### Dialog.Root | Prop | Type | Description | | --- | --- | --- | | `open` | `boolean` | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes, including outside-press and overlay dismissal. | | `modal` | `boolean` | When true, mounts a full-screen input sink that blocks interaction behind the dialog. Outside presses dismiss in both modes; veto them with the outside-interaction callbacks. Defaults to true. | | `children` | `React.ReactNode` | The dialog parts. | ### Dialog.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge trigger behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `disabled` | `boolean` | Prevents the trigger from opening the dialog and removes it from focus registration. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Dialog.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the app-level portal provider's container. | | `displayOrderBase` | `number` | Base DisplayOrder band for the generated ScreenGui; the layer renders at this base plus a global mount counter. Defaults to 1000. | | `children` | `React.ReactNode` | Overlay and content parts. | ### Dialog.Overlay Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error. The primitive owns `Visible`, `Active`, `Selectable` and `Size` from presence and full-screen hit-testing, so values you pass for those are ignored. | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge overlay behavior onto the single child element instead of the textbutton the part renders. Use an activatable button class so press-to-close keeps working. | | `forceMount` | `boolean` | Keeps the overlay mounted while closed and through its exit, bypassing the presence wrapper. | | `children` | `React.ReactNode` | Rendered inside the overlay. With asChild, the single element the behavior merges onto instead. | | `…TextButton props` | `Partial>` | Forwarded onto the rendered textbutton and type-checked against it. Pass BackgroundColor3 and BackgroundTransparency here for a visible dim. Visible and Active are owned by the primitive. | ### Dialog.Content Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error. The primitive owns `Visible` and `Size` from presence and full-screen layer geometry, so values you pass for those are ignored. | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Make the single child element the motion host instead of the frame the part renders. Pass a canvasgroup here to fade the whole surface as one layer. The outside-press boundary becomes that element's first host child. | | `trapFocus` | `boolean` | Traps focus and selection inside the content while open. Defaults to true. | | `restoreFocus` | `boolean` | Restores focus to the previously focused element on close — the trigger, when one opened the dialog. Defaults to true. | | `forceMount` | `boolean` | Bypasses the presence wrapper: the content stays mounted while closed, long exits run to completion, and child state persists between opens. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. Used as-is — there is no default underneath, so a partial config animates only the steps it defines. Omit it for no animation. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press lands outside the content, before dismissal. Call event.preventDefault() to keep the dialog open. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called after onPointerDownOutside for the same outside interaction, before dismissal. Call event.preventDefault() to keep the dialog open. | | `children` | `React.ReactNode` | The surface contents. The first direct host element is the outside-press hit-test boundary. | | `…Frame props` | `Partial>` | Forwarded onto the rendered frame — or onto your asChild element — and type-checked against Frame. The host spans the layer, so styling it paints the full screen; style your panel child instead. A transition owns Position while it runs. | ### Dialog.Close | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge close behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Popover > Anchored surface primitive that owns open state, popper positioning, layered dismissal, and presence motion while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/popover/ `@lattice-ui/react-popover` · Stable direction · import `Popover` · depends on `runtime`, `focus`, `layer`, `motion`, `popper` Popover is the primitive for a non-blocking surface that floats next to the element that opened it: hover cards, inline editors, detail panels, and small forms. It coordinates open state, positioning, dismissal, and exit motion so your component only has to render the floating frame and its contents. Reach for Popover when a surface should **anchor** to a trigger (or a separate anchor), **position itself** with the popper foundation, and **dismiss predictably** — by an explicit close, an outside interaction, or a controlled state change. Unlike Dialog, Popover is **non-modal by default**, so the rest of the UI stays interactive while it is open. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Popover } from "@lattice-ui/react-popover"; ``` ## Anatomy Compose the parts you need. `Root`, `Portal`, and `Content` form the minimum useful surface; `Trigger`, `Anchor`, and `Close` are optional depending on how you drive and position the popover. ```tsx title="Popover anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Popover.Root` | yes | Owns open state and shares it with every part through context. | | `Popover.Trigger` | no | A button that toggles the popover and acts as the default positioning anchor. | | `Popover.Anchor` | no | An explicit anchor to position against when the trigger is not the right reference. | | `Popover.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. | | `Popover.Content` | yes | The positioned, dismissable, motion-driven surface. | | `Popover.Close` | no | A button that closes the popover from inside the content. | ## Examples ### Basic popover The smallest useful popover: an uncontrolled root, a trigger projected onto your own button with `asChild`, and a content surface in a portal. The trigger toggles on activation and doubles as the positioning anchor, an outside press dismisses, and the default content wrapper is an auto-sized `frame` — so your frame inside it just needs a size. ```tsx title="EmoteInfoPopover.tsx" import { Popover } from "@lattice-ui/react-popover"; export function EmoteInfoPopover() { return ( ); } ``` ### Detached anchor Add a `Popover.Anchor` when the surface should point at something other than the button that opens it. Here a help button in a toolbar opens a callout that annotates the quest tracker HUD frame instead of the button itself. A mounted anchor always wins over the trigger as the positioning reference, and with `asChild` it tracks your element directly instead of the frame it renders. ```tsx title="QuestTrackerCallout.tsx" import { Popover } from "@lattice-ui/react-popover"; export function QuestTrackerCallout() { return ( ); } ``` ### Controlled by game state Pass `open` and `onOpenChange` when something outside the popover decides visibility. This onboarding hint opens the moment the player receives their first item and closes through the same channel a trigger would use — an outside press or `Popover.Close` still calls `onOpenChange(false)`, so your state stays the source of truth. No trigger is needed; the anchor alone positions the surface. ```tsx title="InventoryHint.tsx" import { Popover } from "@lattice-ui/react-popover"; export function InventoryHint(props: { hasNewItem: boolean; onDismiss: () => void }) { return ( { if (!open) { props.onDismiss(); } }} > ); } ``` ### Placement and offsets Positioning is tuned entirely on `Popover.Content`. `placement` requests a side (default `"bottom"`), `sideOffset` adds a gap between the anchor and the surface, `alignOffset` shifts the surface along the anchor's cross axis, and `collisionPadding` sets the minimum distance kept from the screen edge (default `8`). When the requested side does not fit, the content flips to the opposite side — and its entrance motion animates from whichever side actually resolved. ```tsx title="StatTooltipPopover.tsx" import { Popover } from "@lattice-ui/react-popover"; export function StatTooltipPopover() { return ( ); } ``` ### Closing from inside `Popover.Close` closes the popover from anywhere inside the content; with `asChild` it merges the close behavior onto your own button. On close, the focus scope restores gamepad selection to whatever was selected before the popover opened — the trigger focuses itself as it opens, so selection lands back on the trigger without any wiring on your side. ```tsx title="LoadoutSavePopover.tsx" import { Popover } from "@lattice-ui/react-popover"; export function LoadoutSavePopover(props: { onSave: () => void }) { return ( ); } ``` ### Custom exit motion `Popover.Content` runs no motion of its own. Pass a `transition` to animate it, and `forceMount` when the node should stay mounted instead of unmounting after the exit finishes — useful when you drive motion yourself or need the instance to persist. The wrapper is a `frame` with or without `asChild`, so `createPopperEntranceRecipe` is the recipe that matches it. ```tsx title="SlowRevealPopover.tsx" import { Popover } from "@lattice-ui/react-popover"; import { createPopperEntranceRecipe } from "@lattice-ui/react-motion"; const SLOW_REVEAL = createPopperEntranceRecipe("top", 16, 0.25); export function SlowRevealPopover() { return ( ); } ``` ## How it behaves ### Open state `Popover.Root` is controllable. Pass `open` and `onOpenChange` to control it, or `defaultOpen` to run uncontrolled (defaults to closed). `Popover.Trigger` toggles the open state on activation and `Popover.Close` closes it; outside-press dismissal goes through the same `setOpen` path. Everything funnels into one state, so controlled and uncontrolled usage behave identically. ### Trigger and anchor resolution The trigger registers itself as the positioning anchor, but only while no `Popover.Anchor` has claimed the slot — a mounted anchor always takes precedence, whether it mounts before or after the trigger. The default trigger is a 150x38 `textbutton` labeled "Toggle Popover"; with `asChild` its `Active`, `Activated` handler, `Selectable={false}`, and ref are merged onto your single child element. `disabled` blocks toggling and removes the trigger from focus tracking. `Popover.Anchor` renders a zero-size transparent frame by default; with `asChild` it tracks your element's geometry directly and renders nothing extra. ### Positioning `Popover.Content` is positioned by the popper foundation. It measures the anchor and the content, then resolves a final placement, flipping to the opposite side when the requested side would collide with the screen edge. Tune it with `placement` (`"top" | "bottom" | "left" | "right"`, default `"bottom"`), `sideOffset` (gap from the anchor, default `0`), `alignOffset` (shift along the anchor's cross axis, default `0`), and `collisionPadding` (minimum distance from the screen edge, default `8`). Until the first measurement resolves, the surface is parked far offscreen and the reveal motion is held back — the content never flashes at an unpositioned location, and the entrance always animates from the placement that actually resolved, not the one you requested. ### Focus and selection `Popover.Content` mounts a focus scope tied to the open state. Because Popover is non-modal by default, the scope is **not trapped** — gamepad and `GuiObject` selection can move freely between the popover and the rest of the screen. The trigger focuses itself just before opening, and the scope restores focus to the previously selected object on close, so selection returns to the trigger without extra wiring. Setting `modal` on the `Root` switches the scope to trapped, keeping selection inside the surface while it is open. ### Dismissal `Popover.Content` participates in dismissable-layer behavior while open. An outside press dismisses it, and when `modal` is `true`, interaction behind the surface is also blocked. Use `onPointerDownOutside` (pointer presses) and `onInteractOutside` (any other outside interaction) to observe or veto those interactions before the popover closes. ### Motion and presence `Popover.Content` runs no motion unless you pass a `transition`. `createPopperEntranceRecipe(placement)` from `@lattice-ui/react-motion` slides the surface 10 pixels in from the resolved placement side while fading `BackgroundTransparency`, on the default wrapper and under `asChild` alike. It takes its own distance and duration, and exits at 0.8x the reveal. Without `forceMount`, the content mounts when the popover opens and unmounts after the exit motion completes. Pass `forceMount` to skip the presence wrapper entirely: the node stays mounted while closed with its `Visible` driven by the motion controller, which is useful when you drive motion yourself or need the instance to persist across open cycles. > **Modal is opt-in** > > Popover defaults to `modal={false}`: the surface floats over the UI without blocking it and without trapping selection. Set `modal` on `Popover.Root` only when the popover should behave like a focused, blocking surface — at which point it traps focus and blocks interaction behind it, much like Dialog. > **The anchor wins over the trigger** > > When both a `Popover.Trigger` and a `Popover.Anchor` are mounted, the content always positions against the anchor. There is no prop to flip this — remove the anchor if the trigger should be the reference again. > **A fade reaches the surface, not its children** > > `BackgroundTransparency` fades one instance's own background, so the surface fades while the labels and icons inside it stay opaque. Fade those with them (`TextTransparency`, `ImageTransparency`), or pass `asChild` with your own `canvasgroup` and `createCanvasGroupPopperEntranceRecipe`, which fades the whole subtree as one composited layer. ## API reference ### Popover.Root | Prop | Type | Description | | --- | --- | --- | | `open` | `boolean` | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes — from the trigger, a close button, outside dismissal, or a controlled update. | | `modal` | `boolean` | When true, blocks interaction behind the popover and traps focus inside it. Defaults to false. | | `children` | `React.ReactNode` | The popover parts. | ### Popover.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge toggle behavior, focus tracking, and the anchor ref onto the single child element instead of the textbutton the part renders. | | `disabled` | `boolean` | Prevents the trigger from toggling the popover and removes it from focus tracking. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Popover.Anchor | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Track the single child element's geometry instead of the frame the part renders. A mounted anchor takes positioning precedence over the trigger. | | `children` | `React.ReactElement` | The element to anchor against. Required when asChild is set. | ### Popover.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. | | `children` | `React.ReactNode` | The content part. | ### Popover.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on. Flips to the opposite side on collision. Defaults to "bottom". | | `sideOffset` | `number` | Gap in pixels between the anchor and the content. Defaults to 0. | | `alignOffset` | `number` | Shift in pixels along the anchor's cross axis. Defaults to 0. | | `collisionPadding` | `number` | Minimum distance in pixels to keep from the screen edge. Defaults to 8. | | `asChild` | `boolean` | Position and animate the single child element instead of the auto-sized frame wrapper the part renders. createPopperEntranceRecipe fits either path; supply a canvasgroup here if you want the whole subtree to fade as one layer. | | `forceMount` | `boolean` | Keeps the content mounted while closed and through exit motion, with Visible driven by the motion controller, instead of unmounting after exit. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; createPopperEntranceRecipe(placement) matches the frame the content renders, on the default path and under asChild alike. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any other outside interaction, before dismissal. | | `children` | `React.ReactNode` | The surface contents. Must be a single valid element when asChild is set. | ### Popover.Close | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge close behavior onto the single child element instead of the textbutton the part renders. Event handlers compose, so the child's own Activated handler still runs. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Tooltip > Hover- and focus-triggered surface primitive that owns open delay, popper positioning, portal layering, and presence motion while you own the content. Source: https://docs.astra-void.xyz/lattice-ui/components/tooltip/ `@lattice-ui/react-tooltip` · Stable direction · import `Tooltip` · depends on `runtime`, `layer`, `motion`, `popper` Tooltip is the primitive for a small, transient surface that explains a control: button hints, icon labels, and stat breakdowns. It opens on hover or gamepad selection and positions itself against the trigger with popper, so your component only renders the label. Reach for Tooltip when a surface should **appear on hover or selection**, **wait a beat before showing** (and skip that wait when moving between nearby triggers), **anchor to its trigger** with collision-aware placement, and **dismiss** when the pointer or selection leaves. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Tooltip } from "@lattice-ui/react-tooltip"; ``` ## Anatomy `Root`, `Trigger`, `Portal`, and `Content` form the working tooltip. Wrap a region (or your whole app) in `Provider` to share open-delay behavior across many tooltips. ```tsx title="Tooltip anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Tooltip.Provider` | no | Shares delay defaults and the skip-delay grace window across the tooltips inside it. | | `Tooltip.Root` | yes | Owns open state and the delayed-open / close logic; shares trigger and content refs. | | `Tooltip.Trigger` | yes | The element whose hover/selection opens and closes the tooltip; the popper anchor. | | `Tooltip.Portal` | yes | Renders the content into a `ScreenGui` outside the local tree. | | `Tooltip.Content` | yes | The popper-positioned, motion-driven, dismissable surface. | ## Examples ### Basic usage The smallest useful tooltip: a provider for timing, a trigger projected onto your own button with `asChild`, and an app-owned label surface inside the portal. Hovering the button waits out the provider delay, then the content animates in above it; moving the pointer away closes it. ```tsx title="StatTooltip.tsx" import { Tooltip } from "@lattice-ui/react-tooltip"; export function StatTooltip() { return ( ); } ``` ### Shared delay across a toolbar One `Provider` around a row of icon buttons gives every tooltip the same delay and — more importantly — the skip window. The first hover waits the full `delayDuration`; once any tooltip has opened, moving to a neighboring trigger within `skipDelayDuration` opens its tooltip after at most that short window, so scrubbing across the toolbar feels instant. ```tsx title="ActionToolbar.tsx" import { Tooltip } from "@lattice-ui/react-tooltip"; const ACTIONS = [ { icon: "rbxassetid://111111", label: "Attack" }, { icon: "rbxassetid://222222", label: "Defend" }, { icon: "rbxassetid://333333", label: "Flee" }, ]; export function ActionToolbar() { return ( {ACTIONS.map((action) => ( ))} ); } ``` ### Rich content `Tooltip.Content` accepts arbitrary children, so a tooltip can be a small card rather than a single line — here a keybind hint with a title row and a description. Keep it non-interactive: the tooltip closes as soon as the pointer leaves the trigger, so buttons inside it are unreachable by design. ```tsx title="KeybindHint.tsx" import { Tooltip } from "@lattice-ui/react-tooltip"; export function KeybindHint() { return ( ); } ``` ### Placement and offset tuning `placement` picks the preferred side (`"top"`, `"bottom"`, `"left"`, `"right"`), `sideOffset` sets the gap from the trigger, `alignOffset` slides the content along that side, and `collisionPadding` keeps it off the viewport edges. Popper flips or shifts away from the preferred side when it would collide, and the default entrance recipe follows the side that was actually resolved. ```tsx title="InventorySlotTooltip.tsx" import { Tooltip } from "@lattice-ui/react-tooltip"; export function InventorySlotTooltip() { return ( ); } ``` ### Controlled and force-open Pass `open` and `onOpenChange` when something outside the tooltip should decide visibility — an onboarding step that pins a hint open until acknowledged, or a tutorial that walks through controls. Hover and selection still report through `onOpenChange`, so you choose whether to honor them; here they are ignored while the onboarding step is active. ```tsx title="OnboardingHint.tsx" import { useState } from "@rbxts/react"; import { Tooltip } from "@lattice-ui/react-tooltip"; export function OnboardingHint() { const [showHint, setShowHint] = useState(true); return ( { // Ignore hover/selection/outside requests; only the button below closes it. }} > setShowHint(false) }} Size={UDim2.fromOffset(72, 24)} Text="Got it" TextColor3={Color3.fromRGB(240, 244, 250)} /> ); } ``` > **Outside interactions still dismiss** > > Even in controlled mode the content runs as a dismissable layer: a press outside asks the tooltip to close through `onOpenChange(false)`. If the hint must survive outside presses, keep your controlled `open` true and ignore those change requests, as above. ### Gamepad selection The trigger listens to `SelectionGained`/`SelectionLost` alongside hover, and selection opens are immediate — no delay — because a gamepad user has already committed to the control. Nothing extra to wire: make the trigger selectable (the default trigger and the `asChild` slot both set `Selectable`) and D-pad focus shows the tooltip the moment the control is selected. ```tsx title="GamepadStatTooltip.tsx" import { Tooltip } from "@lattice-ui/react-tooltip"; export function GamepadStatTooltip() { return ( {/* Selection lands here via the gamepad; the tooltip opens instantly. */} ); } ``` ## How it behaves ### Open behavior `Tooltip.Trigger` tracks two activity sources — hover (`MouseEnter`/`MouseLeave`) and selection focus (`SelectionGained`/`SelectionLost`). The tooltip opens when either source becomes active and closes only when both are inactive, so moving the mouse off a still-selected trigger keeps the tooltip up. Hover opens go through the delay; selection opens call straight into open with no delay, which suits gamepad and keyboard navigation. Setting `disabled` on the trigger stops it from opening (activity is still tracked, but open actions are dropped), closes the tooltip as soon as activity ends, and flipping `disabled` to true mid-hover resets the activity state and closes immediately. A disabled trigger also clears `Active` and `Selectable`, removing it from gamepad selection. ### Open delay and skip window Hover opens wait for a delay before showing. The delay resolves from `Root`'s `delayDuration` if set, otherwise the `Provider`'s `delayDuration` (default `700` ms). A resolved delay of `0` (or less) opens synchronously — set `delayDuration={0}` on a root for an instant tooltip. Leaving the trigger cancels any pending open, and unmounting the root cancels it too. The skip window lives on the provider: every open — hover, selection, or a controlled open through the trigger — stamps a timestamp, and when the next hover open starts within `skipDelayDuration` (default `300` ms) of it, the wait is shortened to at most that skip window. Moving between adjacent triggers under one provider therefore feels instant instead of re-waiting the full delay. ### Positioning `Tooltip.Content` positions itself with popper, anchored to the trigger ref. Set `placement` for the preferred side (`"top"`, `"bottom"`, `"left"`, or `"right"`), `sideOffset` for the gap from the trigger, `alignOffset` to shift along the side, and `collisionPadding` for the minimum distance kept from the viewport edges. Until popper has measured and positioned the content it is parked far off-screen, so it never flashes at the wrong spot; the entrance motion also waits for positioning before it plays. ### Layering `Tooltip.Portal` renders the content into a `ScreenGui` outside the local component tree. With no props it reuses the surrounding portal context; pass `container` to target a specific `PlayerGui` or `displayOrderBase` to order the generated `ScreenGui` against other layered surfaces — either prop switches the portal onto its own provider with those overrides. ### Dismissal `Tooltip.Content` runs as a non-modal dismissable layer: it never blocks interaction behind it, and an outside interaction closes it. `onPointerDownOutside` fires for outside presses and `onInteractOutside` for other outside interactions, both before the close, so you can observe (or record) the interaction. Dismissal goes through the same `setOpen` path as hover, so controlled tooltips see it as an `onOpenChange(false)` request. ### Motion and presence `Tooltip.Content` wraps its children in a `frame` and runs no motion of its own. Pass a `transition` to animate it — `createPopperEntranceRecipe(placement)` keyed to the **resolved** placement slides and fades from the trigger's actual side, even after a collision flip. Pass `forceMount` to keep the content mounted while closed and through its exit, bypassing the presence wrapper when you drive visibility yourself. With `asChild`, your single child is rendered inside the positioned surface with its position zeroed and its `Visible` bound to the motion state. > **The skip window needs a Provider** > > Without a `Tooltip.Provider`, each tooltip still gets the 700 ms default delay, but there is no shared skip window — every hover re-waits the full delay. Wrap a region in a provider whenever neighboring tooltips should hand off instantly, and use `Root`'s own `delayDuration` for per-tooltip overrides. > **Selection opens skip the delay** > > Only hover opens are delayed. `SelectionGained` opens immediately, so gamepad users never wait — and if a delayed hover open is pending when selection arrives, the open simply happens right away. Design your content to be readable at instant-open speeds. > **The default trigger is a transparent placeholder** > > Without `asChild`, `Tooltip.Trigger` renders a transparent 140x36 `textbutton` labeled "Tooltip Trigger" — handy for wiring, not for shipping. In real UI, pass `asChild` and project the hover/selection handlers and anchor ref onto your own button; the slot's `Active` and `Selectable` follow the `disabled` prop. ## API reference ### Tooltip.Provider | Prop | Type | Description | | --- | --- | --- | | `delayDuration` | `number` | Default hover-open delay in milliseconds for tooltips inside this provider. A root's own delayDuration overrides it per tooltip. Defaults to 700. | | `skipDelayDuration` | `number` | Grace window in milliseconds after any tooltip opens; hover opens starting within it wait at most this long instead of the full delay. Defaults to 300. | | `children` | `React.ReactNode` | The tooltips that share these delay defaults and the skip window. | ### Tooltip.Root | Prop | Type | Description | | --- | --- | --- | | `open` | `boolean` | Controlled open state. Pair with onOpenChange; hover, selection, and outside dismissal still report through it. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `delayDuration` | `number` | Hover-open delay for this tooltip in milliseconds, overriding the provider. Use 0 for an instant tooltip. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes (or is requested to change, in controlled mode). | | `children` | `React.ReactNode` | The trigger, portal, and content parts. | ### Tooltip.Trigger | Prop | Type | Description | | --- | --- | --- | | `disabled` | `boolean` | Drops open actions, closes when activity ends, and clears Active/Selectable so the trigger leaves gamepad selection. Defaults to false. | | `asChild` | `boolean` | Merge the hover/selection handlers, Active/Selectable, and the popper anchor ref onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Tooltip.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the content into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. | | `children` | `React.ReactNode` | The content part. | ### Tooltip.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `PopperPlacement` | Preferred side relative to the trigger: "top", "bottom", "left", or "right". Popper may flip it on collision. | | `sideOffset` | `number` | Gap in pixels between the content and the trigger along the placement side. | | `alignOffset` | `number` | Shift in pixels along the placement side. | | `collisionPadding` | `number` | Minimum padding kept between the content and the viewport edges when resolving position. | | `transition` | `MotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) keyed to the resolved placement. | | `forceMount` | `boolean` | Keeps the content mounted while closed and through exit motion, bypassing the presence wrapper. Defaults to false. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any other outside interaction, before dismissal. | | `asChild` | `boolean` | Render the single child element inside the positioned surface; its position is zeroed and its Visible follows the motion state. | | `children` | `React.ReactNode` | The tooltip contents. | ## Related - [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Menu > Anchored action-menu primitive that owns open state, ordered selection movement, popper positioning, and layered dismissal while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/menu/ `@lattice-ui/react-menu` · Stable direction · import `Menu` · depends on `runtime`, `focus`, `layer`, `motion`, `popper` Menu is the primitive for a list of actions that opens from a trigger: context menus, dropdown actions, overflow menus, and command lists. It coordinates open state, ordered item movement, positioning, dismissal, and exit motion so your component only has to render the items and their contents. Reach for Menu when you need a surface of **selectable actions** that **moves selection in order** (gamepad up/down and arrow keys), **anchors** to its trigger through the popper foundation, and **dismisses on selection or outside interaction**. Menu is **modal by default** — selection is trapped inside the open menu and restored to the trigger on close. ## Import ```ts import { Menu } from "@lattice-ui/react-menu"; ``` ## Anatomy Compose the parts you need. `Root`, `Trigger`, `Portal`, and `Content` form the working menu; `Item` makes it useful, and `Group`, `Label`, and `Separator` structure longer lists. ```tsx title="Menu anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Menu.Root` | yes | Owns open state, the item registry, and selection movement. | | `Menu.Trigger` | yes | A button that toggles the menu and acts as the positioning anchor and focus-restore target. | | `Menu.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. | | `Menu.Content` | yes | The positioned, focus-trapped, dismissable, motion-driven surface. | | `Menu.Item` | yes | A selectable action that registers for ordered movement and emits `onSelect`. | | `Menu.Group` | no | A container that groups related items. Supply the layout yourself. | | `Menu.Label` | no | A non-interactive heading for a group or section. | | `Menu.Separator` | no | A thin divider between items or groups. | ## Examples ### Basic actions menu The smallest useful menu: an uncontrolled root, a trigger, and a few items. Every part here renders its default element — the trigger is a `textbutton` labeled `"Toggle Menu"`, and each item is a left-aligned 220x34 `textbutton` that highlights on hover and gamepad selection — so you can wire up actions before styling anything. Selecting an item runs its `onSelect` and closes the menu. ```tsx title="BasicActionsMenu.tsx" import { Menu } from "@lattice-ui/react-menu"; export function BasicActionsMenu() { return ( print("rename")} /> print("duplicate")} /> print("delete")} /> ); } ``` > **Uncontrolled by default** > > Omit `open`/`onOpenChange` and the root owns its state, starting from `defaultOpen` (closed by default). Reach for controlled state only when something outside the menu needs to open or close it — see [Controlled right-click menu](#controlled-right-click-menu). ### Groups, labels, and separators `Group` wraps related items, `Label` puts a non-interactive heading above them, and `Separator` marks a division. None of them register with selection movement — Up/Down skips straight from the last item of one group to the first enabled item of the next. All three render unstyled. As of 0.7.0 `Menu.Group` no longer supplies a vertical `UIListLayout` or forces `AutomaticSize` — it was one of the last two primitives holding an opinion about how children lay out, so give it a layout of its own. `Label` renders no copy, and `Separator` draws nothing until you size and color it. ```tsx title="InventoryItemMenu.tsx" import { Menu } from "@lattice-ui/react-menu"; export function InventoryItemMenu(props: { itemName: string }) { return ( print("equip")}> print("inspect")}> print("drop")}> ); } ``` ### Icon items with asChild `asChild` on `Item` merges the selection behavior — activation, Up/Down movement, hover and gamepad highlight — onto your own single element through the shared `Slot`. The slot's `Active`, `Selectable`, and ref win over the child's own props, and event handlers compose. Use a `textbutton` or `imagebutton` so `Activated` fires. The primitive tracks the highlight but never paints it — read `useMenuItemContext().highlighted` and render it yourself (see [How it behaves](#items-and-activation)). ```tsx title="IconActionsMenu.tsx" import { Menu } from "@lattice-ui/react-menu"; const ACTIONS = [ { id: "trade", label: "Trade", icon: "rbxassetid://1234567890" }, { id: "invite", label: "Invite to party", icon: "rbxassetid://1234567891" }, { id: "block", label: "Block", icon: "rbxassetid://1234567892" }, ]; export function IconActionsMenu(props: { onAction: (id: string) => void }) { return ( {ACTIONS.map((action) => ( props.onAction(action.id)} asChild> ))} ); } ``` ### Controlled right-click menu Pass `open` and `onOpenChange` when something other than the trigger's own activation should open the menu — here a right-click (or long-press-style secondary input) on an inventory slot. The trigger still has to exist because it is the positioning anchor and the focus-restore target; with `asChild` its toggle behavior composes with your slot's own handlers, so a left-click `Activated` also toggles as usual. > **Anchored to the slot, not the pointer** > > This opens the menu **against the trigger**, which is what you want when the slot is small and the menu should hug it. If you want the menu to appear **at the cursor** instead, that is [Context Menu](https://docs.astra-void.xyz/lattice-ui/components/context-menu.md) — it anchors to the click position and needs no controlled state to do so. The trade-off is that Context Menu is pointer-only: it has no ordered gamepad or keyboard movement. ```tsx title="SlotContextMenu.tsx" import { useState } from "@rbxts/react"; import { Menu } from "@lattice-ui/react-menu"; export function SlotContextMenu(props: { slotIcon: string; onAction: (id: string) => void }) { const [open, setOpen] = useState(false); return ( setOpen(true), }} /> props.onAction("use")}> props.onAction("split")}> props.onAction("drop")}> ); } ``` ### Placement tuning `Menu.Content` accepts the popper positioning options. `placement` requests a side (`"top" | "bottom" | "left" | "right"`, default `"bottom"`), `sideOffset` adds a pixel gap between the trigger and the content, `alignOffset` shifts the content along the trigger's cross axis, and `collisionPadding` sets the minimum distance kept from the screen edge (default `8`). The requested side is a preference, not a guarantee — when it would overflow, the popper tries the opposite side, then the two orthogonal sides, and finally clamps the best candidate inside the viewport. ```tsx title="SidebarOverflowMenu.tsx" import { Menu } from "@lattice-ui/react-menu"; export function SidebarOverflowMenu() { return ( print("settings")}> print("help")}> ); } ``` ### Disabled items and staying open `disabled` on an item blocks activation, removes it from gamepad selection, and skips it during Up/Down movement. `onSelect` receives a `MenuSelectEvent`; calling `event.preventDefault()` marks it default-prevented, and the item then skips the automatic close — the one thing the default behavior does — so the menu stays open. That makes toggle-style items possible, like a filter list you can flip several times in one visit. ```tsx title="LootFilterMenu.tsx" import { useState } from "@rbxts/react"; import { Menu } from "@lattice-ui/react-menu"; const RARITIES = ["Common", "Rare", "Epic"]; export function LootFilterMenu(props: { hasLoot: boolean }) { const [enabled, setEnabled] = useState>({ Common: true, Rare: true, Epic: true }); return ( {RARITIES.map((rarity) => ( { event.preventDefault(); setEnabled({ ...enabled, [rarity]: !enabled[rarity] }); }} > ))} print("collect all")}> ); } ``` ## How it behaves ### Open state `Menu.Root` is controllable on `open`/`onOpenChange`, with `defaultOpen` for uncontrolled usage (defaulting to closed). `Menu.Trigger` toggles the open state on `Activated` and on the `Return`/`Space` keys, focusing itself first when it is about to open the menu so focus restoration has a stable target. Selecting an item closes the menu unless the item's `onSelect` calls `preventDefault`. ### Positioning `Menu.Content` is positioned by the popper foundation, anchored to the trigger. It measures the trigger and the content, then evaluates candidate placements in order — the requested side, its opposite, then the two orthogonal sides (which carry a small penalty so they are only chosen when both primary sides overflow) — and picks the first perfect fit or the least-overflowing candidate, clamped inside the viewport with `collisionPadding` kept from every edge. Until the first measurement completes the content is parked off-screen, so it never flashes at the wrong position. Tune the result with `placement` (default `"bottom"`), `sideOffset` (gap from the trigger, default `0`), `alignOffset` (shift along the cross axis, default `0`), and `collisionPadding` (default `8`). ### Focus and ordered movement When the menu opens, the first enabled item is focused automatically and selection is **trapped** inside the content (Menu is modal by default). `Menu.Item` registers itself with the root in render order, and that registry drives ordered movement: pressing `Up`/`Down` on a focused item moves selection to the previous or next available item. Disabled, invisible, and non-selectable items are skipped. Movement stops at the ends of the list — it does not wrap around. When the menu closes, focus is restored to the trigger. ### Items and activation `Menu.Item` activates on click/tap (`Activated`) and on the `Return`/`Space` keys. Activation builds a `MenuSelectEvent` (`{ defaultPrevented, preventDefault() }`) and passes it to `onSelect`; if the event is not default-prevented, the item closes the menu. A disabled item ignores activation and movement keys entirely. `Menu.Item` renders an unstyled `textbutton`. It tracks whether the item is highlighted — by hover **or** by managed keyboard/gamepad focus — and exposes that as `useMenuItemContext().highlighted`, but draws nothing itself. Reporting focus as well as hover matters: an item that never becomes the engine's `SelectedObject` still highlights correctly, and moving the pointer away no longer clears the highlight on the item the keyboard has focused. ### Dismissal `Menu.Content` participates in dismissable-layer behavior: only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a time. Because Menu is modal, a full-screen blocker stops interaction behind the surface, and an outside press dismisses the menu. Before dismissal, `onPointerDownOutside` fires for outside pointer presses and `onInteractOutside` fires for the interaction in general; both receive a `LayerInteractEvent` (`{ originalEvent, defaultPrevented, preventDefault() }`), and calling `preventDefault()` in either handler vetoes the dismissal while still letting you observe the interaction. ### Motion and presence `Menu.Content` runs no motion of its own. Pass a `transition` to animate it — `createPopperEntranceRecipe(placement)` matches the `frame` the content renders, and building it from the **resolved** placement makes a menu the popper flipped above the trigger animate from above. `forceMount` keeps the content mounted through its exit (useful when you drive motion yourself or need the node to persist). The content wrapper is an automatically-sized `frame`, so your surface defines the measured size. > **Menu is modal** > > Menu defaults to `modal={true}`: it traps selection inside the open content and blocks interaction behind it. Set `modal={false}` on `Menu.Root` for a lightweight, non-blocking menu that leaves the rest of the UI interactive — closer to Popover's default behavior. > **The primitive drives the item highlight** > > Before 0.7.0 `Menu.Item` animated your element's `BackgroundColor3` between fixed colors with no opt-out. It no longer touches color — it only reports `highlighted` through `useMenuItemContext()`. A background you set stays put, and you decide whether the highlight animates. > **Selection movement does not wrap** > > `Up` on the first item and `Down` on the last item keep selection where it is instead of cycling to the other end. Order items so the most common actions sit at the top, where selection starts. ## API reference ### Menu.Root | Prop | Type | Description | | --- | --- | --- | | `open` | `boolean` | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes. | | `modal` | `boolean` | When true, traps selection inside the menu and blocks interaction behind it with a full-screen blocker. Defaults to true. | | `children` | `React.ReactNode` | The menu parts. | ### Menu.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the toggle behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `disabled` | `boolean` | Prevents the trigger from toggling the menu and removes it from gamepad selection. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Menu.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. | | `children` | `React.ReactNode` | The content part. | ### Menu.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on. Falls back to the opposite side, then the orthogonal sides, on collision. Defaults to "bottom". | | `sideOffset` | `number` | Gap in pixels between the trigger and the content. Defaults to 0. | | `alignOffset` | `number` | Shift in pixels along the trigger's cross axis. Defaults to 0. | | `collisionPadding` | `number` | Minimum distance in pixels to keep from every screen edge when resolving and clamping placement. Defaults to 8. | | `asChild` | `boolean` | Render the single child element inside the positioned wrapper instead of the frame the part renders. | | `forceMount` | `boolean` | Keeps the content mounted while exit motion runs, instead of unmounting on close. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. Call event.preventDefault() to veto the dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any outside interaction, before dismissal. Call event.preventDefault() to veto the dismissal. | | `children` | `React.ReactNode` | The menu contents. | ### Menu.Item | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `disabled` | `boolean` | Prevents selection, removes the item from gamepad selection, and skips it during ordered movement. | | `onSelect` | `(event: MenuSelectEvent) => void` | Called on activation. Call event.preventDefault() to keep the menu open. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Menu.Group | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the group onto the single child element instead of the frame the part renders. Since 0.7.0 the group has no layout of its own — supply a uilistlayout. | | `children` | `React.ReactElement` | The grouped items to render. Required when asChild is set. | ### Menu.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the label onto the single child element instead of the textlabel the part renders. Pass Text — the part renders no copy of its own. | | `children` | `React.ReactElement` | The label element to render. Required when asChild is set. | ### Menu.Separator | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the separator onto the single child element instead of the frame the part renders. Give it a Size and BackgroundColor3 — it draws nothing on its own. | | `children` | `React.ReactElement` | The divider element to render. Required when asChild is set. | ## Related - [Context Menu](https://docs.astra-void.xyz/lattice-ui/components/context-menu.md) - [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Context Menu > Pointer-anchored action menu that opens at the right-click position, owning open state, popper placement, and layered dismissal while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/context-menu/ `@lattice-ui/react-context-menu` · Feature limited · import `ContextMenu` · depends on `runtime`, `layer`, `motion`, `popper` Context Menu is the primitive for actions that belong to a specific object rather than to a button: right-click a slot, a plot, a player name, and get a menu at the pointer. It differs from [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) in one decisive way — the menu is anchored to **where you clicked**, not to the element you clicked. The trigger is a region, not a button. Reach for Context Menu when the action list belongs to a **region of the screen**, when the menu should appear **under the pointer**, and when a **secondary (right) click** is the natural way to ask for it. Reach for [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) instead when a visible button opens the list, or when gamepad and keyboard users must be able to move through the items. > **Pointer-driven only** > > Context Menu opens on `MouseButton2` and tracks hover on its items (exposed through `useContextMenuItemContext`, for you to render). Unlike Menu, its items do **not** register with the focus manager: there is no automatic focus of the first item, no `Up`/`Down` movement, and no focus restore on close. On a gamepad-first or keyboard-first surface, use [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md). The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. Right-click inside the card to open the menu at the pointer. _Interactive preview._ ## Import ```ts import { ContextMenu } from "@lattice-ui/react-context-menu"; ``` ## Anatomy `Root`, `Trigger`, `Portal`, and `Content` form the working menu; `Item` makes it useful, and `Group`, `Label`, and `Separator` structure longer lists. The part names match Menu's, so moving between the two is mostly a matter of swapping the namespace. ```tsx title="Context Menu anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `ContextMenu.Root` | yes | Owns open state and the pointer position the menu opens at. | | `ContextMenu.Trigger` | yes | The region that listens for a secondary click and reports where it happened. | | `ContextMenu.Portal` | yes | Renders the surface into a `ScreenGui` outside the local tree. | | `ContextMenu.Content` | yes | The pointer-anchored, dismissable surface. | | `ContextMenu.Item` | yes | A clickable action that emits `onSelect` and closes the menu. | | `ContextMenu.Group` | no | A container that groups related items. Supply the layout yourself. | | `ContextMenu.Label` | no | A non-interactive heading for a group or section. | | `ContextMenu.Separator` | no | A thin divider between items or groups. | ## Examples ### Basic context menu An uncontrolled root, a trigger region, and a few items. Right-clicking anywhere inside the trigger opens the menu at the pointer; selecting an item runs its `onSelect` and closes it. Every part renders unstyled, so this example supplies all of it: a size and color for the trigger, a layout and surface for the content, and a size plus label for each item. Nothing here is optional decoration — without it the menu opens and works, but draws nothing. ```tsx title="BasicContextMenu.tsx" import { ContextMenu } from "@lattice-ui/react-context-menu"; const ITEMS = [ { label: "Rename", action: () => print("rename") }, { label: "Duplicate", action: () => print("duplicate") }, { label: "Delete", action: () => print("delete") }, ]; export function BasicContextMenu() { return ( {ITEMS.map((item) => ( ))} ); } ``` > **Hover is tracked, not drawn** > > `ContextMenu.Item` still follows the pointer, but since 0.7.0 it no longer paints a highlight. Read the state with `useContextMenuItemContext()` and render it yourself — see [Highlighting items](#highlighting-items) below. > **Uncontrolled by default** > > Omit `open`/`onOpenChange` and the root owns its state, starting from `defaultOpen` (closed by default). The pointer position is always owned by the root — even when you control `open` yourself, the anchor comes from the last secondary click on the trigger. ### A real trigger region `asChild` merges the secondary-click listener onto your own element, which is the normal way to use this primitive: the trigger is the thing the actions belong to. The child keeps all of its own props and handlers — the slot only adds `InputBegan` and `Active` — so a left-click `Activated` on the same element still does whatever it did before. ```tsx title="PlotContextMenu.tsx" import { ContextMenu } from "@lattice-ui/react-context-menu"; export function PlotContextMenu(props: { plotName: string; onSelect: () => void }) { return ( print("build")}> print("clear")}> ); } ``` ### Groups, labels, and separators `Group` wraps related items in a 220-wide vertical-layout frame, `Label` puts a muted, non-interactive heading above them, and `Separator` draws a 1px divider. None of them are interactive — they exist to give a long list structure. ```tsx title="InventorySlotContextMenu.tsx" import { ContextMenu } from "@lattice-ui/react-context-menu"; export function InventorySlotContextMenu(props: { icon: string }) { return ( print("equip")}> print("inspect")}> print("drop")}> ); } ``` ### Placement tuning `ContextMenu.Content` takes the same popper options as the other anchored primitives, but the anchor is a zero-height virtual frame at the pointer, as wide as the measured content. That is what makes the default `placement="bottom"` drop the menu's top-left corner at the cursor, the way a desktop context menu behaves. Change `placement` when you want it to grow the other way — near the bottom of the screen the popper flips it for you regardless. ```tsx title="MinimapContextMenu.tsx" import { ContextMenu } from "@lattice-ui/react-context-menu"; export function MinimapContextMenu() { return ( print("ping")}> print("waypoint")}> ); } ``` ### Highlighting items The item tracks hover for you but does not paint it. `useContextMenuItemContext()` returns `{ highlighted, disabled }` — `highlighted` is already false while disabled, so one branch covers both. Read it from a component rendered *inside* the item, since that is where the context lives: ```tsx title="ContextMenuRow.tsx" import { ContextMenu, useContextMenuItemContext } from "@lattice-ui/react-context-menu"; function RowSurface(props: { label: string }) { const { highlighted, disabled } = useContextMenuItemContext(); return ( ); } export function ContextMenuRow(props: { label: string; disabled?: boolean }) { return ( ); } ``` Because the highlight is now yours, you also choose whether it animates. Wrap the transparency in a response motion if you want the old eased feel. ### Disabled items and staying open `disabled` on an item blocks activation and clears its highlight state. `onSelect` receives a `ContextMenuSelectEvent`; calling `event.preventDefault()` marks it default-prevented and the item skips the automatic close — the one thing the default behavior does — so the menu stays open. That makes toggle-style items possible. ```tsx title="MarkerContextMenu.tsx" import { useState } from "@rbxts/react"; import { ContextMenu } from "@lattice-ui/react-context-menu"; export function MarkerContextMenu(props: { canDelete: boolean }) { const [pinned, setPinned] = useState(false); return ( {/* Stays open so you can see the state flip. */} { event.preventDefault(); setPinned(!pinned); }} > print("delete")}> ); } ``` ### Controlled open state Pass `open` and `onOpenChange` when something outside the menu needs to close it — a round ending, a selection being cleared, a different panel taking over. The trigger still owns *where* the menu appears, so controlling `open` does not mean you have to supply a position. ```tsx title="ControlledContextMenu.tsx" import { useEffect, useState } from "@rbxts/react"; import { ContextMenu } from "@lattice-ui/react-context-menu"; export function ControlledContextMenu(props: { editable: boolean }) { const [open, setOpen] = useState(false); // Leaving edit mode should take the menu with it. useEffect(() => { if (!props.editable) { setOpen(false); } }, [props.editable]); return ( print("cut")} /> print("paste")} /> ); } ``` ## How it behaves ### Open state and the anchor `ContextMenu.Root` is controllable on `open`/`onOpenChange`, with `defaultOpen` for uncontrolled usage (defaulting to closed). `ContextMenu.Trigger` watches `InputBegan` and reacts only to `Enum.UserInputType.MouseButton2`: it converts the raw pointer position into the inset-adjusted space that `GuiObject.AbsolutePosition` uses, stores it on the root, and opens the menu. That stored position survives until the next secondary click, so a controlled root can reopen the menu at the same spot. A `disabled` trigger ignores the secondary click entirely and never updates the stored position. ### Positioning `ContextMenu.Content` mounts an invisible virtual anchor at the stored pointer position and hands it to the shared popper machinery. The anchor has **zero height and the measured content's width**, which is what makes the resolved placement land the menu's top-left corner at the cursor instead of centering it under the pointer. From there it behaves like every other anchored surface: the requested `placement` (default `"bottom"`) is a preference, and on collision the popper tries the opposite side, then the orthogonal sides, then clamps the best candidate inside the viewport with `collisionPadding` (default `8`) kept from every edge. `sideOffset` and `alignOffset` shift the result. Until the first measurement completes the content is parked off-screen, so it never flashes at the wrong position. ### Items and activation `ContextMenu.Item` activates on `Activated` and builds a `ContextMenuSelectEvent` (`{ defaultPrevented, preventDefault() }`) for `onSelect`; if the event is not default-prevented, the item closes the menu. A disabled item ignores activation. The item renders an unstyled `textbutton`. It tracks hover through `MouseEnter`/`MouseLeave` and exposes the result as `useContextMenuItemContext().highlighted`, but draws nothing itself — rendering the highlight is yours, under `asChild` or not. ### Dismissal `ContextMenu.Content` participates in dismissable-layer behavior: only the top-most enabled layer receives outside interactions, so nested overlays dismiss one at a time. Context Menu is **modal by default**, so a full-screen blocker stops interaction behind the surface and an outside press dismisses the menu. Before dismissal, `onPointerDownOutside` fires for outside pointer presses and `onInteractOutside` fires for the interaction in general; both receive a `LayerInteractEvent` (`{ originalEvent, defaultPrevented, preventDefault() }`), and calling `preventDefault()` in either handler vetoes the dismissal while still letting you observe the interaction. ### Motion and presence `ContextMenu.Content` runs no motion of its own. Pass a `transition` to animate it; `createPopperEntranceRecipe(placement)` from `@lattice-ui/react-motion` matches the `frame` the content renders, and taking the **resolved** placement makes the motion originate from the side the menu actually landed on — so a menu the popper flipped above the pointer animates from above. `forceMount` keeps the content mounted through its exit. The content wrapper is an automatically-sized `frame`, so your surface defines the measured size. > **No ordered movement** > > `ContextMenu.Item` does not register a focus node. There is no automatic focus on open, no `Up`/`Down` movement between items, and no focus restore on close — `modal` here means "blocks pointer interaction behind the surface", not "traps selection". If your surface has to be operable without a mouse, build it with [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) instead. > **The item highlight is yours to render** > > Before 0.7.0 `ContextMenu.Item` animated your element's `BackgroundColor3` between fixed hover colors with no opt-out. It no longer touches color at all — it only reports `highlighted` through `useContextMenuItemContext()`. Set the background yourself and it will stay put. ## API reference ### ContextMenu.Root | Prop | Type | Description | | --- | --- | --- | | `open` | `boolean` | Controlled open state. Pair with onOpenChange. | | `defaultOpen` | `boolean` | Initial open state for uncontrolled usage. Defaults to false. | | `onOpenChange` | `(open: boolean) => void` | Called whenever the open state changes. | | `modal` | `boolean` | When true, blocks pointer interaction behind the surface with a full-screen blocker. Defaults to true. | | `children` | `React.ReactNode` | The context menu parts. | ### ContextMenu.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the secondary-click listener onto the single child element instead of the textbutton the part renders. | | `disabled` | `boolean` | Ignores the secondary click, so the menu never opens from this region. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### ContextMenu.Portal | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | Target PlayerGui to render the surface into. Defaults to the surrounding portal context's container. | | `displayOrderBase` | `number` | Base DisplayOrder for the generated ScreenGui, used to order it against other layers. Defaults to the surrounding portal context's value. | | `children` | `React.ReactNode` | The content part. | ### ContextMenu.Content | Prop | Type | Description | | --- | --- | --- | | `placement` | `"top" \| "bottom" \| "left" \| "right"` | Requested side to position the content on, relative to the pointer anchor. Falls back to the opposite side, then the orthogonal sides, on collision. Defaults to "bottom". | | `sideOffset` | `number` | Gap in pixels between the pointer anchor and the content. Defaults to 0. | | `alignOffset` | `number` | Shift in pixels along the anchor's cross axis. Defaults to 0. | | `collisionPadding` | `number` | Minimum distance in pixels to keep from every screen edge when resolving and clamping placement. Defaults to 8. | | `asChild` | `boolean` | Render the single child element inside the positioned frame instead of the part's own children. | | `forceMount` | `boolean` | Keeps the content mounted while exit motion runs, instead of unmounting on close. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createPopperEntranceRecipe(placement) for a placement-aware entrance. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Called when a pointer press occurs outside the content, before dismissal. Call event.preventDefault() to veto the dismissal. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Called for any outside interaction, before dismissal. Call event.preventDefault() to veto the dismissal. | | `children` | `React.ReactNode` | The menu contents. | ### ContextMenu.Item Renders a `TextButton`. Unknown props forward onto it and are type-checked against it, so a prop `TextButton` does not accept is a compile error. The primitive owns `Active` and `Selectable` derived from disabled, so values you pass for those are ignored. | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge item behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `disabled` | `boolean` | Prevents activation and forces highlighted to false. | | `onSelect` | `(event: ContextMenuSelectEvent) => void` | Called on activation. Call event.preventDefault() to keep the menu open. | | `children` | `React.ReactNode` | The item contents. Must be a single element when asChild is set. | | `…TextButton props` | `Partial>` | Forwarded onto the rendered textbutton and type-checked against it. Active and Selectable are owned by the primitive, derived from disabled. | ### ContextMenu.Group Renders a `Frame`. Unknown props forward onto it and are type-checked against it, so a prop `Frame` does not accept is a compile error. > **Supply the layout yourself** > > As of 0.7.0 `ContextMenu.Group` renders a plain frame: no `UIListLayout`, no `AutomaticSize`. It was one of the last two primitives holding an opinion about how children lay out. Add a `uilistlayout` child, as `Select.Group` and `Combobox.Group` have always required. | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the group onto the single child element instead of the frame the part renders. | | `children` | `React.ReactNode` | The grouped items, plus the layout that arranges them. Must be a single element when asChild is set. | | `…Frame props` | `Partial>` | Forwarded onto the rendered frame and type-checked against it. | ### ContextMenu.Label | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the label onto the single child element instead of the textlabel the part renders. | | `children` | `React.ReactNode` | The label contents. Must be a single element when asChild is set. | | `…TextLabel props` | `Partial>` | Forwarded onto the rendered textlabel and type-checked against it. Pass Text and TextColor3 here — the part renders no copy of its own. | ### ContextMenu.Separator | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the separator onto the single child element instead of the frame the part renders. | | `children` | `React.ReactNode` | Rendered inside the separator. Must be a single element when asChild is set. | | `…Frame props` | `Partial>` | Forwarded onto the rendered frame and type-checked against it. Give it a Size and BackgroundColor3 — it draws nothing on its own. | ## Related - [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) - [Positioning with popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Toast > Queued notification primitive that owns scheduling, visibility limits, and exit timing while you own the toast surface and its parts. Source: https://docs.astra-void.xyz/lattice-ui/components/toast/ `@lattice-ui/react-toast` · Stable direction · import `Toast` · depends on `runtime`, `layer`, `motion` Toast is the primitive for transient, non-blocking notifications: saved-changes confirmations, reward pop-ups, connection warnings, and inline status messages. A `Provider` owns the queue — enqueuing, the visible-count limit, per-toast duration, and exit timing — so your component only renders the surface for each toast and reacts to actions. Reach for Toast when messages should **stack and expire on their own**, stay **capped to a few at a time**, and **animate out** without you tracking timers by hand. You drive it imperatively with the `useToast` hook from anywhere inside the provider. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Toast, useToast } from "@lattice-ui/react-toast"; ``` ## Anatomy `Provider` and `Viewport` form the minimum system: the provider owns the queue and the viewport is where you render it. > **You render the queue** > > Before 0.7.0 a bare `` rendered a surface for every visible toast. It no longer does — it renders its children and nothing else, so a bare viewport shows nothing. Map `useToast().visibleToasts` onto `Toast.Root` yourself, which is what the `asChild` path always required. `Root`, `Title`, `Description`, `Action`, and `Close` are how you build each surface. ```tsx title="Toast anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Toast.Provider` | yes | Owns the queue, default duration, visible cap, and exit timing; exposes `useToast`. | | `Toast.Viewport` | yes | The container the stack lives in. Renders your children — supply the layout and the queue. | | `Toast.Root` | no | A single toast surface that animates between visible and hidden. | | `Toast.Title` | no | The toast heading. | | `Toast.Description` | no | The toast supporting text. | | `Toast.Action` | no | A button that runs `onAction` when activated. | | `Toast.Close` | no | A button that runs `onClose` when activated. | ## Examples ### Basic setup A provider at the top of the UI, a button that enqueues toasts, and a viewport that renders the queue. `enqueue` returns the new toast's id in case you want to remove it early. The viewport is where the work is: map `visibleToasts`, give each record a `Toast.Root`, and wire `onExitComplete` to `finalize` so a toast is only dropped from the queue once its exit has finished. `visible={!record.exiting}` is what starts that exit. ```tsx title="ToastStack.tsx" import { Toast, useToast } from "@lattice-ui/react-toast"; export function ToastStack() { const toast = useToast(); return ( {toast.visibleToasts.map((record) => ( toast.finalize(record.id)} Size={UDim2.fromOffset(340, 64)} visible={!record.exiting} > ))} ); } ``` Then place that stack wherever it belongs. The viewport takes no position props of its own, so an anchored wrapper decides the corner: ```tsx title="SaveStatusToasts.tsx" import { Toast, useToast } from "@lattice-ui/react-toast"; import { ToastStack } from "./ToastStack"; function SaveButton() { const toast = useToast(); return ( toast.enqueue({ title: "Layout saved", description: "Your changes are live for everyone.", durationMs: 3000, }), }} /> ); } export function SaveStatusToasts() { return ( ); } ``` > **Imperative, not declarative** > > There is no `open`/`onOpenChange` on a toast. You add toasts by calling `enqueue` and remove them with `remove`; the provider decides what is visible and when each one leaves. Treat the queue as the source of truth, not local component state. ### Undo action toast `ToastOptions` carries only data — `id`, `title`, `description`, `durationMs` — never callbacks. To build an undo toast, key the pending undo by the id `enqueue` returns, then wire `Toast.Action` to the stored payload while mapping `visibleToasts`. This example uses `Viewport asChild` because it wants a `frame` it fully controls; mapping the queue is the same either way. Both `onAction` and `onClose` are plain callbacks; neither removes the toast for you, so call `remove(record.id)` in each handler. ```tsx title="UndoDeleteToasts.tsx" import { useRef } from "@rbxts/react"; import { Toast, useToast } from "@lattice-ui/react-toast"; function DeleteButton(props: { pendingUndos: Map }) { const toast = useToast(); return ( { // remove "Sword" from your data model here, then offer the undo const id = toast.enqueue({ title: "Sword deleted", durationMs: 6000 }); props.pendingUndos.set(id, "Sword"); }, }} /> ); } function UndoViewport(props: { pendingUndos: Map; onUndo: (itemName: string) => void; }) { const toast = useToast(); return ( {toast.visibleToasts.map((record) => ( { const itemName = props.pendingUndos.get(record.id); if (itemName !== undefined) { props.onUndo(itemName); props.pendingUndos.delete(record.id); } toast.remove(record.id); }} asChild > toast.remove(record.id)} asChild> ))} ); } export function UndoDeleteToasts(props: { onUndo: (itemName: string) => void }) { const pendingUndos = useRef(new Map()).current; return ( ); } ``` ### Duration tuning and sticky toasts Each toast lives for its own `durationMs`; when you omit it, the provider's `defaultDurationMs` applies. A `durationMs` of `0` (or less) makes the toast sticky — it never expires and stays until something calls `remove` or `clear`. Passing your own `id` lets a different code path remove a sticky toast later, like clearing a "connection lost" warning on reconnect. ```tsx title="ConnectionToasts.tsx" import { Toast, useToast } from "@lattice-ui/react-toast"; function ConnectionStatus() { const toast = useToast(); return ( toast.enqueue({ title: "Pong", durationMs: 1500 }), }} /> toast.enqueue({ title: "Settings synced" }), }} /> toast.enqueue({ id: "connection-lost", title: "Connection lost", description: "Reconnecting...", durationMs: 0, // sticky: never expires on its own }), }} /> { toast.remove("connection-lost"); toast.enqueue({ title: "Reconnected", durationMs: 2000 }); }, }} /> ); } export function ConnectionToasts() { return ( ); } ``` ### Queue behavior under burst `maxVisible` caps how many toasts render at once; everything past the cap waits in order and surfaces as earlier toasts leave. Here six pickups arrive at once with `maxVisible={2}`: the first two show, the other four queue. One caveat to design around: a toast's clock starts at `enqueue`, but expiry is only *evaluated* while it is inside the visible window. A toast that waited in the queue longer than its `durationMs` exits almost immediately once it surfaces. Under heavy bursts, give toasts a duration long enough to cover the expected wait, collapse the burst into a single summary toast ("Picked up 6 items"), or use sticky toasts with manual removal. ```tsx title="LootBurstToasts.tsx" import { Toast, useToast } from "@lattice-ui/react-toast"; const DROPS = ["Iron Ore", "Gold Ore", "Emerald", "Ancient Coin", "Rune Shard", "Phoenix Feather"]; function LootChest() { const toast = useToast(); return ( { for (const drop of DROPS) { toast.enqueue({ title: `Picked up ${drop}`, durationMs: 4000 }); } }, }} /> ); } export function LootBurstToasts() { return ( ); } ``` ### Placing the viewport The viewport is app-positioned: it takes no position props of its own, so put it inside an anchored wrapper frame to choose the corner or edge it lives in — here, top-center for announcement-style toasts. The stack direction and gap come from the layout you put inside the viewport. Reach for `Viewport asChild` when you need a different instance class than the `frame` the viewport renders. It makes no difference to the queue — you map `visibleToasts` either way. ```tsx title="TopCenterToasts.tsx" import { Toast } from "@lattice-ui/react-toast"; export function TopCenterToasts() { return ( {/* ...the rest of your UI... */} ); } ``` ### Custom toast motion `Toast.Root` runs no motion unless you pass a `transition`. `createToastResponseRecipe()` — a 0.14s calm, steady settle on the appearance target — animates `BackgroundTransparency` between `0` (visible) and `1` (hidden), and is the recipe the root used to apply for you. Rebuild it with a new duration, or write a `ResponseMotionConfig` by hand. The `transition` prop only exists on roots you render yourself, so custom motion implies a custom viewport. Keep the duration at or under about 0.16s: the provider drops exiting toasts after a fixed ~160ms window regardless of your transition, so a slower fade gets cut off mid-motion. ```tsx title="SnappyToasts.tsx" import { Toast, useToast } from "@lattice-ui/react-toast"; import { createToastResponseRecipe } from "@lattice-ui/react-motion"; // equivalent hand-built config: // const SNAPPY_FADE: ResponseMotionConfig = { settle: { duration: 0.08, tempo: "swift", tone: "responsive" } }; const SNAPPY_FADE = createToastResponseRecipe(0.08); function SnappyViewport() { const toast = useToast(); return ( {toast.visibleToasts.map((record) => ( ))} ); } export function SnappyToasts() { return ( {/* ...UI that enqueues toasts... */} ); } ``` ## How it behaves ### The queue `Toast.Provider` keeps an ordered queue of records. `enqueue(options)` appends a toast and returns its `id` (auto-generated as `toast-N` unless you pass `id`), stamping `createdAtMs` at that moment. `remove(id)` starts a toast's exit — or drops it instantly if it is still waiting beyond the visible cap — and is a no-op for a toast that is already exiting. `clear()` empties the whole queue immediately, skipping exit motion. While any toasts exist, the provider runs a `RunService.Heartbeat` connection that prunes expired and finished-exiting toasts each frame; the connection disconnects once the queue drains, so an idle provider costs nothing per frame. ### Visibility limit `maxVisible` (default `3`, clamped to at least `1`) caps how many toasts render at once; the rest wait in the queue in arrival order. `useToast().visibleToasts` is the capped slice the viewport renders, while `toasts` is the full queue. An exiting toast still occupies its visible slot for the length of the exit window, so the next queued toast surfaces only after the exit completes and the record is dropped. ### Duration and expiry Each toast expires after its own `durationMs`, falling back to the provider's `defaultDurationMs` (default `4000`, clamped to `>= 0`). A duration of `0` or less makes a toast sticky — it never expires on its own and must be removed via `remove`, a wired-up `Toast.Close`, or `clear`. Expiry is measured from `createdAtMs` — the moment of `enqueue` — but only checked while the toast is inside the visible window. Toasts waiting beyond the cap never expire in the queue, yet one that waited longer than its duration exits on nearly the first frame it becomes visible. Plan burst-heavy flows around this (longer durations, summary toasts, or sticky toasts). ### Exit timing and motion When a visible toast's time elapses (or `remove` is called on it), the provider marks the record `exiting` and keeps it for a fixed ~160ms window so its exit motion can play before the record is dropped. Wire each root's `visible` prop to `!record.exiting` — that is what triggers the exit — and `onExitComplete` to `finalize(record.id)`. `Toast.Root` runs no motion unless you pass a `transition`. `createToastResponseRecipe()` (0.14s, appearance target) is the recipe that used to be applied for you; it animates `BackgroundTransparency` between `0` while visible and `1` while hidden. The 160ms drop window is not derived from your transition, so exits meaningfully slower than that get truncated. With `asChild`, the motion ref and `Visible` binding move onto your single child element. ### What each part renders Every part renders unstyled, so the whole surface is yours: - `Toast.Viewport` — a `Frame` holding your children. No layout, no queue markup. - `Toast.Root` — a `Frame`. Its `Visible` is presence-driven; everything else is yours. - `Toast.Title` and `Toast.Description` — `TextLabel`s with no copy of their own. Pass `Text` from the record; they also render children, so a `uipadding` works directly on them. - `Toast.Action` and `Toast.Close` — `TextButton`s that merge `Active`, `Selectable`, and the `Activated` handler. Neither touches the queue: call `remove(id)` yourself. > **Provider placement** > > Mount `Toast.Provider` high in your UI tree, above everything that calls `useToast`. `useToast` reads the provider through context and throws if used outside it. The `Viewport` does not have to be a direct child — it only needs to be somewhere inside the same provider. > **Action and Close do not remove the toast** > > `onAction` and `onClose` are plain callbacks; activating the button does not touch the queue. Call `remove(id)` inside both handlers — otherwise the toast lingers until its duration expires, and a sticky toast never leaves. > **Toast payloads are data, not callbacks** > > `ToastOptions` has no action or handler field. To attach behavior to a toast, keep the payload in your own state keyed by the id `enqueue` returns, and look it up from the record's `id` when rendering a custom surface. ## API reference ### Toast.Provider | Prop | Type | Description | | --- | --- | --- | | `defaultDurationMs` | `number` | Fallback lifetime for toasts without their own durationMs. Clamped to >= 0; 0 makes omitted-duration toasts sticky. Defaults to 4000. | | `maxVisible` | `number` | Maximum toasts rendered at once; the rest wait in the queue. Clamped to >= 1. Defaults to 3. | | `children` | `React.ReactNode` | The UI tree that enqueues toasts and renders the viewport. | ### useToast Returns the imperative API for the nearest provider. Throws when called outside a `Toast.Provider`. | Prop | Type | Description | | --- | --- | --- | | `toasts` | `Array` | The full queue in arrival order, including toasts waiting beyond the visible cap. | | `visibleToasts` | `Array` | The capped slice currently eligible to render. Map over this in custom viewports. | | `enqueue` | `(options: ToastOptions) => string` | Appends a toast and returns its id (auto-generated as toast-N unless options.id is set). | | `remove` | `(id: string) => void` | Starts the exit for a visible toast, or drops a still-queued toast instantly. No-op if the toast is already exiting. | | `clear` | `() => void` | Empties the queue immediately, without exit motion. | `ToastOptions` accepts `id`, `title`, `description`, and `durationMs` — all optional, all plain data. Each queue entry is a `ToastRecord` carrying those fields plus `createdAtMs` and, once leaving, `exiting`/`exitStartedAtMs`; custom viewports read `id`, `title`, `description`, and `exiting` from it. The package also exports the provider's pure queue helpers — `enqueueToast`, `dequeueToast`, `getVisibleToasts`, and `pruneExpiredToasts` — which are useful for testing custom viewports against the exact scheduling rules. ### Toast.Viewport | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Render the single child element instead of the frame the part renders. You map visibleToasts to Toast.Root either way. | | `children` | `React.ReactNode` | Extra content appended after the rendered toasts, or the single element when asChild is set. | ### Toast.Root | Prop | Type | Description | | --- | --- | --- | | `visible` | `boolean` | Drives the shown/hidden motion state. Wire to !record.exiting in custom viewports. Defaults to true. | | `transition` | `ResponseMotionConfig` | Show/hide motion. None by default; createToastResponseRecipe() gives the 0.14s settle. Exits slower than ~160ms are cut off by the provider's drop window. | | `asChild` | `boolean` | Apply the Visible binding and motion ref to the single child element instead of the frame the part renders. | | `children` | `React.ReactNode` | The toast contents. | ### Toast.Title | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Render the single child element instead of the textlabel the part renders. | | `children` | `React.ReactElement` | The element to render, typically a textlabel showing record.title. Required when asChild is set. | ### Toast.Description | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Render the single child element instead of the textlabel the part renders. | | `children` | `React.ReactElement` | The element to render, typically a textlabel showing record.description. Required when asChild is set. | ### Toast.Action | Prop | Type | Description | | --- | --- | --- | | `onAction` | `() => void` | Called when the action button is activated. Run your action, then call remove(id) — activation does not remove the toast. | | `asChild` | `boolean` | Merge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Toast.Close | Prop | Type | Description | | --- | --- | --- | | `onClose` | `() => void` | Called when the close button is activated. Wire this to remove(id) — activation does not remove the toast on its own. | | `asChild` | `boolean` | Merge Active, Selectable, and the Activated handler onto the single child element instead of the textbutton the part renders. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ## Related - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Tabs > Tablist primitive that owns the active value, registers triggers in order, moves selection with the arrow keys, and reveals panels with presence motion. Source: https://docs.astra-void.xyz/lattice-ui/components/tabs/ `@lattice-ui/react-tabs` · Stable direction · import `Tabs` · depends on `runtime`, `focus`, `layer`, `motion` Tabs is the primitive for switching between mutually exclusive views: settings categories, inventory sections, shop pages, or any place where one panel is visible at a time. It owns the active value, keeps its triggers ordered, handles arrow-key movement between them, and mounts the matching panel with presence motion. Reach for Tabs when a set of triggers should drive **value-based selection** — exactly one active at a time — with **keyboard and gamepad movement** across the list and **panels that mount and unmount** as the value changes. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Tabs } from "@lattice-ui/react-tabs"; ``` ## Anatomy Compose `Root` around a `List` of `Trigger`s and one `Content` per value. Every `Trigger` and `Content` is tied to the active value through a shared `value` string. ```tsx title="Tabs anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Tabs.Root` | yes | Owns the active value, the trigger registry, and orientation; shares them through context. | | `Tabs.List` | yes | A container `frame` that groups the triggers. | | `Tabs.Trigger` | yes | A `textbutton` that selects its `value` on activation, selection, or Enter/Space. | | `Tabs.Content` | yes | A panel that mounts and animates while its `value` is active. | ## Examples ### Basic tabs An uncontrolled tab group. `defaultValue` picks the starting panel, and the default `Trigger` renders a `textbutton` whose label is its `value`. The `List` and `Content` defaults are zero-size transparent frames, so pass `asChild` with your own sized elements to lay them out. ```tsx title="ShopTabs.tsx" import { Tabs } from "@lattice-ui/react-tabs"; export function ShopTabs() { return ( ); } ``` ### Controlled tabs Drive the active value from your own state with `value`/`onValueChange`. Anything outside the tab group — a hotkey, a tutorial step, a deep link — can now switch panels by setting state. ```tsx title="SettingsTabs.tsx" import { useState } from "@rbxts/react"; import { Tabs } from "@lattice-ui/react-tabs"; const CATEGORIES = ["general", "audio", "graphics"]; export function SettingsTabs() { const [tab, setTab] = useState("general"); return ( {CATEGORIES.map((category) => ( ))} {CATEGORIES.map((category) => ( ))} ); } ``` ### Vertical tabs Set `orientation="vertical"` to move selection with Up/Down instead of Left/Right. Orientation is behavioral only — lay the list out yourself with a vertical `uilistlayout`. ```tsx title="QuestCategories.tsx" import { Tabs } from "@lattice-ui/react-tabs"; export function QuestCategories() { return ( ); } ``` ### Custom triggers with asChild Pass `asChild` on `Trigger` to supply your own button while keeping registration, activation, and movement wiring. The trigger's response motion still animates the child's `BackgroundColor3` and `TextColor3` between the built-in active/inactive palette on selection. ```tsx title="StyledTabs.tsx" import { Tabs } from "@lattice-ui/react-tabs"; function StyledTrigger(props: { value: string; label: string }) { return ( ); } export function StyledTabs() { return ( ); } ``` ### Disabled triggers A disabled trigger cannot be activated, is skipped by arrow-key movement, and never becomes the active value. If the active value becomes disabled, `Root` moves the selection to the next enabled trigger automatically. ```tsx title="PrestigeTabs.tsx" import { Tabs } from "@lattice-ui/react-tabs"; export function PrestigeTabs(props: { prestigeUnlocked: boolean }) { return ( ); } ``` ### Keeping panels mounted By default a panel unmounts after its exit animation. Pass `forceMount` to keep it in the tree at all times — Tabs then only toggles its `Visible` property. Use this when a panel is expensive to rebuild, like a `viewportframe` world map. ```tsx title="MapTabs.tsx" import { Tabs } from "@lattice-ui/react-tabs"; export function MapTabs() { return ( ); } ``` ## How it behaves ### Value state `Tabs.Root` is controllable. Pass `value` and `onValueChange` to control it, or `defaultValue` to run uncontrolled. The active value is a plain string that each `Trigger` and `Content` matches against. `onValueChange` fires only with a defined value, so you never receive an `undefined` selection. When the active value points at no enabled trigger — on first mount with no default, or after the selected trigger is removed or disabled — `Root` resolves a replacement: it falls back to the first enabled trigger, or to the next enabled trigger after the one that was last selected. This keeps a valid panel visible as triggers mount, unmount, or toggle their `disabled` state. ### Trigger registration and selection Each `Tabs.Trigger` registers itself with `Root` in mount order, exposing a stable `order` used to resolve fallbacks and arrow-key movement. A trigger becomes active in three ways: pointer activation, Roblox `SelectionGained` (so moving a gamepad cursor onto a trigger selects it immediately), and pressing Enter or Space while focused. Disabled triggers set `Active` and `Selectable` to `false`, are skipped during movement, and never become the active value. `Tabs.Trigger` registers with the focus system through `useFocusNode`, so it participates in gamepad selection alongside other focusable nodes. ### Orientation and arrow keys `orientation` defaults to `"horizontal"`. It controls which arrow keys move the selection: Left/Right when horizontal, Up/Down when vertical. Pressing a movement key focuses the next enabled trigger in that direction and selects it in one step. Orientation is purely behavioral — it does not lay out the `List`, so add your own `uilistlayout` (as in the examples) to position the triggers. ### Panels and presence `Tabs.Content` is tied to a `value` and is present only while that value is active. By default it mounts through a `Presence` boundary running a surface-reveal recipe, so the panel animates in when selected and animates out when another value takes over, unmounting after the exit completes. Override the recipe with `transition`. Pass `forceMount` to keep the panel mounted at all times — the content stays in the tree and toggles its `Visible` property based on the active value and motion phase. > **Bring your own layout** > > The default `Tabs.List` and `Tabs.Content` render transparent frames sized `UDim2.fromOffset(0, 0)`. They group and reveal children but do not lay anything out. In practice, pass `asChild` with your own sized `frame`, as every example above does, so panels and lists occupy real space. > **Default trigger visuals** > > The built-in `Tabs.Trigger` renders a 132x34 `textbutton` whose `Text` is its `value`, with response motion between an active and inactive color. With `asChild`, the same motion still drives your child's `BackgroundColor3` and `TextColor3` between the built-in palette values whenever the selected state changes — plan your custom styling around that. ## API reference ### Tabs.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Controlled active value. Pair with onValueChange. | | `defaultValue` | `string` | Initial active value for uncontrolled usage. When omitted, the first enabled trigger is selected. | | `onValueChange` | `(value: string) => void` | Called whenever the active value changes. Always receives a defined value. | | `orientation` | `"horizontal" \| "vertical"` | Arrow-key movement axis. Defaults to "horizontal". | | `children` | `React.ReactNode` | The List and Content parts. | ### Tabs.List | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the list onto the single child element instead of the frame the part renders. | | `children` | `React.ReactNode` | The trigger elements. Required as a single element when asChild is set. | ### Tabs.Trigger | Prop | Type | Description | | --- | --- | --- | | `value` (required) | `string` | The value this trigger selects when activated. | | `asChild` | `boolean` | Merge selection behavior onto the single child element instead of the textbutton the part renders. | | `disabled` | `boolean` | Removes the trigger from selection and movement and prevents it from becoming active. Defaults to false. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Tabs.Content | Prop | Type | Description | | --- | --- | --- | | `value` (required) | `string` | The value this panel is shown for. | | `asChild` | `boolean` | Merge the panel onto the single child element instead of the frame the part renders. | | `forceMount` | `boolean` | Keeps the panel mounted at all times and toggles visibility instead of unmounting on exit. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createSurfaceRevealRecipe() for a rise-and-fade. | | `children` | `React.ReactNode` | The panel contents. | ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Accordion > A collapsible disclosure primitive that owns open-value state, single/multiple expansion modes, and per-item presence motion while you own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/accordion/ `@lattice-ui/react-accordion` · Stable direction · import `Accordion` · depends on `runtime`, `focus`, `layer`, `motion` Accordion is the primitive for a stack of disclosure sections where each section can expand to reveal content: FAQ lists, settings groups, inventory categories, and quest logs. It owns which items are open, keyed by a string `value`, and handles the reveal/exit motion of each section's content so your component only renders the header, trigger, and body. Reach for Accordion when you have **multiple labelled sections** that share an expansion policy — only one open at a time (`single`) or any number open at once (`multiple`) — and you want that policy, plus per-section motion, handled for you. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Accordion } from "@lattice-ui/react-accordion"; ``` ## Anatomy Compose `Root` around a list of `Item`s. Each item carries a unique `value`, wraps a `Header`/`Trigger` pair, and a `Content` body that mounts only while the item is open. ```tsx title="Accordion anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Accordion.Root` | yes | Owns the open-value state and the expansion policy, sharing both through context. Renders no instance of its own. | | `Accordion.Item` | yes | Declares one section by `value`; derives its own open state from the root. | | `Accordion.Header` | no | A transparent layout frame for the trigger; purely structural. | | `Accordion.Trigger` | yes | A button that toggles its item open or closed. | | `Accordion.Content` | yes | The body that mounts while the item is open. Animates if given a `transition`. | ## Examples ### Basic accordion An uncontrolled `single` root seeded with `defaultValue`. Every part renders unstyled — the trigger is a `textbutton` with no label of its own, so pass `Text` and a size or you will have a working accordion you cannot see. Because `Root` renders no instance, the item frames sit directly under your layout container. ```tsx title="FaqAccordion.tsx" import { Accordion } from "@lattice-ui/react-accordion"; export function FaqAccordion() { return ( print(`open section: ${value}`)} > ); } ``` ### Multiple sections open `type="multiple"` switches the value to an array and lets any number of items stay open at once — the right fit for filter panels and stat sheets where sections are independent. Items always toggle closed in this mode; `collapsible` is not needed. ```tsx title="InventoryFilters.tsx" import { Accordion } from "@lattice-ui/react-accordion"; export function InventoryFilters() { return ( print(`open sections: ${(value as Array).join(", ")}`)} > ); } ``` ### Collapsible single By default, `type="single"` keeps one item permanently open — re-activating the open trigger does nothing. Add `collapsible` when every panel should be closable, leaving the accordion fully collapsed. This settings panel starts on the audio section but can be shut entirely. ```tsx title="SettingsAccordion.tsx" import { Accordion } from "@lattice-ui/react-accordion"; export function SettingsAccordion() { return ( ); } ``` ### Controlled accordion Pass `value` and `onValueChange` when something outside the accordion needs to open sections — a "track quest" button, a deep link from a notification, or a reset. In single mode the value is a string, and `""` means nothing is open, so external code can both jump to a section and collapse everything. ```tsx title="QuestLog.tsx" import { useState } from "@rbxts/react"; import { Accordion } from "@lattice-ui/react-accordion"; export function QuestLog() { const [value, setValue] = useState>(""); return ( setValue("daily") }} Size={UDim2.fromOffset(260, 28)} Text="Jump to daily quests" TextColor3={Color3.fromRGB(240, 244, 252)} /> ); } ``` ### Rotating chevron trigger Use `asChild` on `Trigger` to project the toggle behavior onto your own button and add a chevron that flips when the section opens. Item open state is not exposed to your components (see [How it behaves](#open-state-stays-internal)), so control the root and derive `open` from your own value — which is also what you branch on for the trigger's colors, since the primitive no longer writes them. ```tsx title="ChevronAccordion.tsx" import { useState } from "@rbxts/react"; import { Accordion } from "@lattice-ui/react-accordion"; const SECTIONS = [ { value: "party", label: "Party", body: "Invite friends and manage your squad." }, { value: "guild", label: "Guild", body: "Guild bank, ranks, and weekly goals." }, ]; export function ChevronAccordion() { const [value, setValue] = useState>("party"); return ( {SECTIONS.map((section) => { const open = value === section.value; return ( ); })} ); } ``` ### Keeping content mounted `forceMount` keeps the content frame mounted while its item is closed (visibility is still bound to presence), so children keep their state and you can measure or drive motion yourself. Pair it with `transition` to give the body a reveal — here `createSurfaceRevealRecipe` for a 12-pixel rise over 0.2 seconds. ```tsx title="PatchNotes.tsx" import { Accordion } from "@lattice-ui/react-accordion"; import { createSurfaceRevealRecipe } from "@lattice-ui/react-motion"; const DEEP_REVEAL = createSurfaceRevealRecipe(12, 0.2); export function PatchNotes() { return ( ); } ``` ## How it behaves ### Open-value state `Accordion.Root` is controllable on `value`/`onValueChange`, with `defaultValue` for uncontrolled usage. State is stored as the item value(s) that are open, not booleans. The `type` prop selects the policy: - `type="single"` (the default) keeps at most one item open. The value is treated as a single string; opening a new item replaces the previously open one, and `onValueChange` receives a string (`""` when everything is collapsed). - `type="multiple"` lets any number of items be open at once. The value is an array of open item values, de-duplicated, and `onValueChange` receives an array. The root normalizes whatever shape it is given: in single mode an array collapses to its first entry and an empty string means nothing is open; in multiple mode a lone string is wrapped and duplicates are dropped. The same normalization and toggle logic are exported as [`normalizeAccordionValue` and `nextAccordionValues`](#helpers) if you need to mirror them outside the tree. `Accordion.Item` reads the root's open values and considers itself open when its `value` is in that set. Each `Accordion.Trigger` toggles its own item through the shared toggle, so controlled and uncontrolled accordions behave identically. ### Collapsible `collapsible` only affects `type="single"`. When `true`, activating the currently open trigger collapses it, leaving nothing open. When `false` (the default), the open item cannot be collapsed by clicking its own trigger — one item always stays open. In `type="multiple"`, items can always be toggled closed regardless of `collapsible`. ### Structure and layout `Accordion.Root` renders no instance of its own — only a context provider — so item frames become direct children of whatever container you render around the accordion, and a `uilistlayout` on that container stacks them. `Accordion.Item` and `Accordion.Header` each render a transparent frame with no size of its own (or your element via `asChild`). Every part forwards GUI props onto the instance it renders, so size and position them yourself — nothing auto-flows inside an item. ### Focus and selection `Accordion.Trigger` renders a `textbutton` that toggles on `Activated` and also on the `Return` and `Space` keys via `InputBegan`, so gamepad and keyboard activation both work without extra wiring. A disabled item's trigger ignores activation and key input alike, and drops `Active`. The trigger sets `Selectable={false}` on itself; if you need it reachable by gamepad selection, render your own selectable element through `asChild`. With `asChild`, the toggle behavior is merged onto your single child element through the shared `Slot`: the slot's `Active`, `Selectable`, event handlers, and ref win over the child's own props, and event handlers compose. Use a `textbutton` or `imagebutton` so `Activated` fires. ### Trigger color motion The trigger's `BackgroundColor3` is yours as of 0.7.0. It used to animate between a fixed open/closed palette with no opt-out; the primitive now writes no color at all, in either mode. Derive it from the value you control on the root. ### Content presence and motion `Accordion.Content` is presence-driven: it mounts when its item opens and unmounts when it closes, holding through any exit transition first. It runs no motion of its own and renders an unstyled frame. To animate it, pass a `PresenceMotionConfig` as `transition` — `createSurfaceRevealRecipe()` from `@lattice-ui/react-motion` gives the rise-and-fade this part used to run. That recipe animates `BackgroundTransparency`, so give the body a background or animate its children instead. Pass `forceMount` to keep it mounted while closed and through its exit, bypassing the presence wrapper so you can drive motion or measurement yourself. With `asChild`, your single child element is rendered in place of the frame the part renders and its `Visible` is bound to the presence state. ### Open state stays internal The accordion's item context (open, value, disabled) is internal to the package — there is no hook or render-prop for reading whether an item is open from your own components. When your visuals need to branch on open state, like the rotating chevron above, control the root with `value`/`onValueChange` and derive the flag from your own state. > **Controlled vs uncontrolled values** > > Match the shape of `value`/`defaultValue` to `type`. With `type="single"` pass a string; with `type="multiple"` pass an array of strings. The root normalizes mismatches, but keeping the shape consistent makes your `onValueChange` handler predictable. > **Header is layout-only** > > `Accordion.Header` renders a transparent frame purely to position the trigger — it carries no state and no behavior. Skip it if your layout does not need it, or replace it via `asChild`. > **The primitive owns the trigger background** > > Before 0.7.0 the trigger drove your element's `BackgroundColor3` between fixed open and closed accents, with no opt-out. It no longer writes color in either mode, so a background you set stays put. > **Default trigger label vs children** > > The trigger renders no text of its own as of 0.7.0 — it used to label itself `"Expand"`/`"Collapse"`. Pass a `Text` prop, which forwards onto the `textbutton`, or render children inside it. ## API reference ### Accordion.Root | Prop | Type | Description | | --- | --- | --- | | `type` | `"single" \| "multiple"` | Expansion policy. "single" keeps at most one item open; "multiple" allows many. Defaults to "single". | | `value` | `string \| Array` | Controlled open value(s). Use a string for single mode and an array for multiple mode. Pair with onValueChange. | | `defaultValue` | `string \| Array` | Initial open value(s) for uncontrolled usage. Defaults to "" in single mode and [] in multiple mode. | | `onValueChange` | `(value: string \| Array) => void` | Called whenever the open value(s) change. Receives a string in single mode and an array in multiple mode. | | `collapsible` | `boolean` | In single mode, allows the open item to be collapsed back to none by re-activating its trigger. Defaults to false. No effect in multiple mode. | | `children` | `React.ReactNode` | The accordion items. | ### Accordion.Item | Prop | Type | Description | | --- | --- | --- | | `value` | `string` | Unique key identifying this item. Determines whether the item is open based on the root's open value(s). | | `disabled` | `boolean` | Prevents the item's trigger from toggling. Defaults to false. | | `asChild` | `boolean` | Merge item layout onto the single child element instead of the frame the part renders. | | `children` | `React.ReactNode` | The header/trigger and content for this item. | ### Accordion.Header | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the header onto the single child element instead of rendering the default transparent frame. | | `children` | `React.ReactElement` | The header contents, typically an Accordion.Trigger. | ### Accordion.Trigger | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge trigger behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set; otherwise rendered inside the default button. | ### Accordion.Content In addition to the props below, `Accordion.Content` forwards any extra GUI props (such as `Size`, `Position`, and `BackgroundColor3`) onto the rendered `frame`. | Prop | Type | Description | | --- | --- | --- | | `forceMount` | `boolean` | Keeps the content mounted while closed and through its exit motion, instead of unmounting when the item closes. | | `transition` | `PresenceMotionConfig` | Reveal/exit motion. None by default; pass createSurfaceRevealRecipe() for a rise-and-fade. | | `asChild` | `boolean` | Merge content behavior onto the single child element instead of rendering the default frame. The child's Visible property is bound to presence. | | `children` | `React.ReactNode` | The body contents shown while the item is open. | ### Helpers The package also exports the pure functions behind the root's state logic, useful for tests or for deriving the next value outside the component tree. ```ts import { nextAccordionValues, normalizeAccordionValue } from "@lattice-ui/react-accordion"; // The normalization Root applies to value/defaultValue for a given type. normalizeAccordionValue("single", ["a", "b"]); // ["a"] normalizeAccordionValue("multiple", ["a", "a", "b"]); // ["a", "b"] // The toggle reducer: what the open values become after activating a trigger. nextAccordionValues("single", ["a"], "a", true); // [] (collapsible) nextAccordionValues("multiple", ["a"], "b", false); // ["a", "b"] ``` ## Related - [Controlled state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Avatar > An image-with-fallback primitive that tracks load status, debounces the fallback with a delay, and lets your component own the visuals. Source: https://docs.astra-void.xyz/lattice-ui/components/avatar/ `@lattice-ui/react-avatar` · Stable direction · import `Avatar` · depends on `runtime` Avatar represents a player or entity with an image, falling back to placeholder content when there is no source or the image has not loaded. It tracks the load lifecycle of the underlying `ImageLabel` and coordinates when the image versus the fallback is shown, so you never flash a placeholder during a fast load or leave an empty box on a broken asset. Reach for Avatar wherever you show a thumbnail that might be missing or slow — headshots, group icons, item images — and want the image-then-fallback handoff handled for you, including a short delay before the fallback appears so quick loads stay seamless. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Avatar } from "@lattice-ui/react-avatar"; ``` ## Anatomy Compose `Root` around an `Image` and a `Fallback`. The root owns the source and status; the image shows once loaded and the fallback shows otherwise. The root itself renders no instance — it is a pure context provider — so both parts mount directly into whatever parent you place the avatar in. ```tsx title="Avatar anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Avatar.Root` | yes | Owns the source, load status, and the fallback delay; shares them through context. | | `Avatar.Image` | yes | Renders the image and reports its load status back to the root. | | `Avatar.Fallback` | no | Placeholder shown while the image is missing, loading past the delay, or errored. | ## Examples ### Basic image with initials fallback The smallest useful avatar: a source on the root, the default 40x40 circular image, and an initials fallback. The fallback uses `asChild` to render your own label — the default fallback is a placeholder `textlabel` with `"AB"` text, so real content should replace it rather than sit inside it. ```tsx title="BasicAvatar.tsx" import { Avatar } from "@lattice-ui/react-avatar"; export function BasicAvatar() { return ( ); } ``` ### Tuning the fallback delay `delayMs` controls how long a loading avatar stays blank before the fallback appears. The default is `250`, which absorbs most fast loads without a placeholder flash; raise it when your art direction prefers a longer blank window, or set it to `0` to show the fallback the moment loading starts. The delay only applies while loading — an empty or absent `src` skips it and shows the fallback immediately, and an errored load always shows it. ```tsx title="LoadoutSlotAvatar.tsx" import { Avatar } from "@lattice-ui/react-avatar"; export function LoadoutSlotAvatar(props: { iconSrc?: string }) { return ( ); } ``` ### Player headshot from a UserId The most common source in a Roblox game: a `rbxthumb://` headshot URL built from a `UserId`. The engine resolves and caches the thumbnail itself, `Avatar.Image` reports `loaded` once the `ImageLabel` finishes, and the initials cover the gap for players whose thumbnails are slow or unavailable. ```tsx title="PlayerHeadshot.tsx" import { Avatar } from "@lattice-ui/react-avatar"; export function PlayerHeadshot(props: { userId: number; displayName: string }) { const src = `rbxthumb://type=AvatarHeadShot&id=${props.userId}&w=150&h=150`; const initials = props.displayName.sub(1, 2).upper(); return ( ); } ``` ### Status ring composition Presence indicators are app-owned visuals: the primitive handles only the image-versus-fallback handoff, so a status ring is just your own frame composed around it. Because `Avatar.Root` renders no instance, both parts mount straight into the ring frame; `asChild` on the image lets you center it inside the slightly larger wrapper. ```tsx title="StatusAvatar.tsx" import { Avatar } from "@lattice-ui/react-avatar"; export function StatusAvatar(props: { userId: number; online: boolean }) { const src = `rbxthumb://type=AvatarHeadShot&id=${props.userId}&w=150&h=150`; const statusColor = props.online ? Color3.fromRGB(64, 200, 120) : Color3.fromRGB(96, 104, 122); return ( ); } ``` ### Avatar stack A party-members row with overlapping avatars. Negative `uilistlayout` padding pulls each wrapper over the previous one, descending `ZIndex` keeps the leftmost member on top, and a stroke in the panel's background color creates the separation cut. Each member gets their own `Avatar.Root`, so slow thumbnails resolve independently. ```tsx title="PartyStack.tsx" import { Avatar } from "@lattice-ui/react-avatar"; const PARTY = [ { userId: 156, initials: "BH" }, { userId: 261, initials: "SH" }, { userId: 1179762, initials: "JN" }, ]; export function PartyStack() { return ( {PARTY.map((member, index) => ( ))} ); } ``` ## How it behaves ### Load status `Avatar.Root` tracks an `AvatarStatus` of `"idle"`, `"loading"`, `"loaded"`, or `"error"`. On mount and whenever `src` changes, it enters `"loading"` if a non-empty source is set, or `"error"` if the source is empty or absent — the `"idle"` member exists in the union but the built-in parts never produce it. Changing `src` restarts the cycle: status resets to `"loading"` and the fallback delay timer starts over, with a sequence guard so a stale timer from a previous source cannot fire. `Avatar.Image` reports the actual load result. It checks its `ImageLabel`'s `IsLoaded` property immediately and subscribes to `GetPropertyChangedSignal("IsLoaded")`, setting the shared status to `"loaded"` once the engine finishes the asset; if its resolved source is empty it reports `"error"` instead. The image is only `Visible` while the status is `"loaded"`, so a broken or in-flight asset never shows as an empty box. ### Source resolution `Avatar.Image` resolves its source from its own `src` prop first, then falls back to the root's `src`. Set the source once on the root for the common case, or override it per image when one avatar composition needs a different asset than the shared context. ### Fallback timing `Avatar.Fallback` derives visibility from the status and the delay: hidden once `"loaded"`, always shown on `"error"`, and otherwise shown only after the delay has elapsed. The root starts a `delayMs` timer (default `250`, clamped to a minimum of `0`) when a source begins loading; until it fires, the fallback stays hidden so a fast load never flashes a placeholder. When there is no source, the delay is treated as elapsed immediately and the fallback appears at once. This rule is exported as `resolveAvatarFallbackVisible(status, delayElapsed)` alongside the `AvatarStatus` type, so custom status-driven parts can share the exact same visibility logic. ### Default parts and asChild `Avatar.Root` renders no instance of its own — it only provides context — so `Image` and `Fallback` mount directly into the surrounding parent and you control layout entirely from outside. The default `Avatar.Image` is a 40x40 circular `imagelabel` with a transparent background; the default `Avatar.Fallback` is a 40x40 circular `textlabel` with placeholder `"AB"` text, and children passed without `asChild` render inside that label rather than replacing it. With `asChild`, `Avatar.Image` merges the resolved `Image` source, load-bound `Visible`, and its status-tracking ref onto your single child element, and `Avatar.Fallback` merges only the derived `Visible`. Both parts error if `asChild` is set without a child. > **Render both Image and Fallback together** > > Keep `Avatar.Image` and `Avatar.Fallback` mounted as siblings at the same time. Visibility of each is driven by the shared status — the image hides itself until loaded and the fallback hides itself until needed — so you should not conditionally mount one or the other yourself. > **asChild images must be imagelabels** > > `Avatar.Image` narrows its ref with `IsA("ImageLabel")` before watching `IsLoaded`. If your `asChild` child is any other class — including an `imagebutton` — load tracking never attaches, the status never reaches `"loaded"`, and the image stays invisible. Project onto an `imagelabel` only. > **The default fallback has placeholder text** > > Without `asChild`, `Avatar.Fallback` renders a `textlabel` whose `Text` is `"AB"` and puts your children inside it — custom content will sit on top of that placeholder text. For real fallback visuals (initials, an icon), pass your element with `asChild` so it replaces the default label. > **Roblox image loading** > > Status comes from the `ImageLabel.IsLoaded` signal, so it reflects the engine's own asset pipeline. Pass a resolved asset string — a `rbxassetid://` id, a `rbxthumb://` URL, or the result of `Players.GetUserThumbnailAsync` — as `src`. An empty string is treated as an error and shows the fallback immediately. ## API reference ### Avatar.Root | Prop | Type | Description | | --- | --- | --- | | `src` | `string` | Default image source shared with Avatar.Image through context. An empty or absent source resolves to the error status and shows the fallback immediately. | | `delayMs` | `number` | Milliseconds to wait before showing the fallback while loading, so fast loads do not flash a placeholder. Defaults to 250 and is clamped to a minimum of 0. | | `children` | `React.ReactNode` | The image and fallback parts. The root renders no instance of its own, so children mount into the surrounding parent. | ### Avatar.Image | Prop | Type | Description | | --- | --- | --- | | `src` | `string` | Image source for this part. Overrides the root's src when set; otherwise the root's src is used. | | `asChild` | `boolean` | Merge the resolved source, load-bound visibility, and status-tracking ref onto the single child element instead of rendering the default 40x40 circular imagelabel. The child must be an imagelabel. | | `children` | `React.ReactElement` | The element to render. Required when asChild is set. | ### Avatar.Fallback | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Merge the derived visibility onto the single child element instead of rendering the default 40x40 circular textlabel. | | `children` | `React.ReactElement` | The placeholder element to render, such as initials or an icon. Required when asChild is set; otherwise rendered inside the default label on top of its placeholder text. | ## Related - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) --- # Progress > Progress-value primitive that owns a clamped numeric range and feeds a motion-driven indicator, plus a standalone spinner for indeterminate work. Source: https://docs.astra-void.xyz/lattice-ui/components/progress/ `@lattice-ui/react-progress` · Stable direction · import `Progress` · depends on `runtime`, `motion` Progress is the primitive for visualizing how far along a task is: loading bars, download meters, XP bars, and quest trackers. `Root` clamps the value against a maximum and derives a `0..1` ratio; `Indicator` animates a fill to that ratio; and `Spinner` provides a self-rotating element for work that has no measurable progress. Reach for Progress when you have a **bounded value** you want to render as a fill, or when you only need an **indeterminate** busy indicator. The primitive owns the math and the motion — your component owns the colors, sizing, and surrounding layout. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { Progress } from "@lattice-ui/react-progress"; ``` The package also exports the value helpers `clampProgressValue` and `resolveProgressRatio`, so readouts outside the primitive can reuse the exact same math (see [Percent readout](#percent-readout)). ## Anatomy `Root` provides the value context that `Indicator` reads. `Spinner` is independent: it needs no `Root` and is used on its own for indeterminate states. ```tsx title="Progress anatomy" ``` | Part | Required | Responsibility | | --- | --- | --- | | `Progress.Root` | yes | Clamps the value to `[0, max]`, derives the fill ratio, and shares it via context. Renders no instance of its own. | | `Progress.Indicator` | no | A clipped window whose width animates to the current ratio, containing the fill. | | `Progress.Spinner` | no | A standalone, self-rotating element for indeterminate work. Does not use `Root`. | ## Examples ### Determinate loading bar The core composition: a controlled `value` against a `max`, with the default indicator inside a track frame you own. `Root` renders no instance — the indicator sizes itself in scale relative to your track — so the surrounding frame decides the bar's pixels, corners, and background. Each `value` change animates the fill toward the new ratio instead of snapping. ```tsx title="DownloadBar.tsx" import { useState } from "@rbxts/react"; import { Progress } from "@lattice-ui/react-progress"; export function DownloadBar() { const [percent, setPercent] = useState(20); return ( setPercent(math.min(100, percent + 10)) }} Size={UDim2.fromOffset(120, 28)} Text="Download more" TextColor3={Color3.fromRGB(240, 244, 252)} /> ); } ``` ### Indeterminate state Set `indeterminate` when work is running but has no measurable endpoint — connecting, matchmaking, waiting on a server. The indicator stops tracking the value and renders a fixed partial fill (`0.35` of the track width) that reads as "busy". Flip the prop back off and the same indicator animates to the real ratio, so a single bar can cover both phases of a task. ```tsx title="MatchmakingBar.tsx" import { Progress } from "@lattice-ui/react-progress"; export function MatchmakingBar(props: { searching: boolean; loadPercent: number }) { return ( ); } ``` ### Standalone spinner `Progress.Spinner` needs no `Root` — drop it anywhere you want a rotating busy glyph. `spinning` starts and stops the rotation loop (and hides the element while stopped), and `speedDegPerSecond` sets how fast it turns. The default render is a 22x22 accent ring with an orbiting dot. ```tsx title="SaveIndicator.tsx" import { Progress } from "@lattice-ui/react-progress"; export function SaveIndicator(props: { saving: boolean }) { return ( ); } ``` ### Percent readout A label next to the bar should agree with the fill exactly, including clamping and the `max` floor. Rather than re-deriving `value / max` by hand, use the exported `resolveProgressRatio` helper — it is the same function `Root` uses internally, so an out-of-range value that renders as a full bar also reads as `100%`. ```tsx title="LevelProgress.tsx" import { Progress, resolveProgressRatio } from "@lattice-ui/react-progress"; export function LevelProgress(props: { xp: number; xpForNextLevel: number }) { const ratio = resolveProgressRatio(props.xp, props.xpForNextLevel); const percentText = `${math.floor(ratio * 100)}%`; return ( ); } ``` ### Custom fill and motion `transition` overrides how the fill width settles after each value change; `asChild` swaps the frame the indicator renders for your own fill element. The default recipe settles in 0.12s — pass `createProgressResponseRecipe` from `@lattice-ui/react-motion` with a longer duration for a smoother glide (a good fit for health bars, where a slow drain reads better than a snap), or a shorter one for near-instant tracking. This is one of the few transitions that still has a default in 0.7.0: the fill is geometry the primitive computes from `value`, so the motion that follows that value stays part of the primitive's behavior rather than decoration. ```tsx title="HealthBar.tsx" import { Progress } from "@lattice-ui/react-progress"; import { createProgressResponseRecipe } from "@lattice-ui/react-motion"; const SMOOTH_DRAIN = createProgressResponseRecipe(0.3); export function HealthBar(props: { health: number; maxHealth: number }) { return ( ); } ``` ## How it behaves ### Value and range `Progress.Root` accepts a `value` and a `max` (default `100`, floored to at least `1`). The value is clamped to `[0, max]`, and the fill ratio is `clampedValue / max`. `Root` is controllable: pass `value` to drive it from app state, or `defaultValue` (default `0`) to run uncontrolled. `onValueChange` mirrors the [core controllable-state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) contract, though in practice progress is almost always controlled — nothing inside the primitive changes the value on its own. `Root` renders no `GuiObject`; it only provides context (`value`, `max`, `ratio`, `indeterminate`) to its children. The track — the visible background of the bar — is whatever frame you place `Root` inside. ### The shared math Clamping and ratio derivation live in two exported helpers: `clampProgressValue(value, max)` clamps against the floored max, and `resolveProgressRatio(value, max, indeterminate?)` returns the `0..1` ratio (or the fixed `0.25` indeterminate ratio). `Root` uses these internally, so a percent label or custom readout built on the same helpers can never disagree with the rendered fill. ### Indeterminate Set `indeterminate` on `Root` to signal work without a known endpoint. The shared ratio resolves to a fixed `0.25` for anything reading context, and `Progress.Indicator` renders a fixed `0.35`-width fill instead of tracking the value — a partial bar you can style as a busy state. The value props are still accepted and clamped while indeterminate, so switching the flag off animates the fill straight to the real ratio. Use `Spinner` instead when you want a rotating glyph rather than a bar. ### Indicator structure and motion `Progress.Indicator` renders a transparent, clipped (`ClipsDescendants`) window anchored to the left of your track. The window's `Size` animates from `UDim2.fromScale(0, 1)` toward `UDim2.fromScale(ratio, 1)` using the default progress response recipe from `@lattice-ui/react-motion` — `createProgressResponseRecipe()`, a 0.12s swift layout settle. The fill inside always spans the window at full scale, so the visual effect is a fill growing along the track. Pass `transition` (a `ResponseMotionConfig`) to override the recipe — rebuild it with `createProgressResponseRecipe(duration)` for a slower or snappier settle, or supply your own `target`/`settle` config. By default the fill is a solid `Color3.fromRGB(102, 156, 255)` frame and any `children` render inside it; with `asChild`, your child element becomes the fill and its `Position` and `Size` are forced to fill the window. ### Spinner rotation `Progress.Spinner` rotates its root `GuiObject` every `RunService.Heartbeat`, advancing `Rotation` by `speedDegPerSecond` (default `180`) times the frame delta — smooth at any frame rate. When `spinning` is `false`, the connection is disconnected and the element is hidden (`Visible = false`); rotation is not reset, so re-enabling resumes from the last angle. The default render is a 22x22 ring (an accent `uistroke` on a fully rounded frame) with a small accent dot; with `asChild`, your child element is the one rotated and its `Visible` property is bound to `spinning`. > **Root renders nothing — bring your own track** > > `Progress.Root` is a pure context provider, and the indicator sizes itself in scale. Always place `Root` inside a frame that defines the bar's pixel size and background; without one, the indicator has nothing to measure against and nothing behind it. > **Out-of-range input clamps, never errors** > > A `value` above `max` renders a full bar, a negative value renders empty, and a `max` below `1` is floored to `1`. This makes progress safe to drive from raw replication or arithmetic without guarding every update — but it also means a bug that overshoots `max` looks like a finished bar, so validate upstream if "over 100%" is meaningful in your data. > **Spinner stands alone** > > `Progress.Spinner` does not read `Root` context — it is a self-contained indeterminate indicator you can place anywhere, including outside any `Progress.Root`. It keeps rotating as long as it is mounted and `spinning` is `true`, whether or not it is on screen, so unmount long-lived spinners you no longer need rather than merely hiding them yourself. ## API reference ### Progress.Root | Prop | Type | Description | | --- | --- | --- | | `value` | `number` | Controlled progress value. Clamped to [0, max]. | | `defaultValue` | `number` | Initial value for uncontrolled usage. Defaults to 0. | | `onValueChange` | `(value: number) => void` | Called when the controllable value changes. | | `max` | `number` | Upper bound of the range. Defaults to 100; floored to a minimum of 1. | | `indeterminate` | `boolean` | Marks the progress as having no known endpoint. Forces the shared ratio to 0.25 and the indicator to a fixed 0.35-width fill. Defaults to false. | | `children` | `React.ReactNode` | The indicator and any surrounding content. Root renders no instance of its own. | ### Progress.Indicator | Prop | Type | Description | | --- | --- | --- | | `transition` | `ResponseMotionConfig` | Overrides the default progress response recipe (a 0.12s settle) used to animate the fill width. | | `asChild` | `boolean` | Render your own fill element instead of the default accent frame. Its Position and Size are forced to fill the animated window. | | `children` | `React.ReactElement` | The fill element to render. Required when asChild is set; otherwise rendered inside the default fill. | ### Progress.Spinner | Prop | Type | Description | | --- | --- | --- | | `spinning` | `boolean` | Whether the spinner rotates and is visible. Stopping does not reset Rotation. Defaults to true. | | `speedDegPerSecond` | `number` | Rotation speed in degrees per second, applied per Heartbeat frame delta. Defaults to 180. | | `asChild` | `boolean` | Rotate your own element instead of the default ring. Its Visible property is bound to spinning. | | `children` | `React.ReactElement` | The element to rotate. Required when asChild is set. | ## Related - [Controllable state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) --- # Scroll Area > Scroll-container primitive that wraps a Roblox ScrollingFrame, tracks its canvas metrics, and drives custom scrollbars and thumbs with overflow-aware visibility. Source: https://docs.astra-void.xyz/lattice-ui/components/scroll-area/ `@lattice-ui/react-scroll-area` · Stable direction · import `ScrollArea` · depends on `runtime` Scroll Area is the primitive for building a scroll container with custom-styled scrollbars on top of Roblox's native `ScrollingFrame`. `Viewport` is the scrolling surface; `Root` reads its canvas metrics and decides when scrollbars should show; and `Scrollbar`/`Thumb` render a draggable indicator wired to the viewport's `CanvasPosition`. `Corner` fills the gap where both axes overlap. Reach for Scroll Area when you want the **native scroll feel** of a `ScrollingFrame` — wheel, touch drag, momentum — but with **scrollbars you style yourself** and **overflow-aware visibility** that hides them when there is nothing to scroll. ## Preview The component running live in the browser — the same `@rbxts/react` tree Roblox renders, fully interactive. _Interactive preview._ ## Import ```ts import { ScrollArea } from "@lattice-ui/react-scroll-area"; ``` ## Anatomy `Root` provides the metrics context and renders no instance of its own. `Viewport` holds your content. Add a `Scrollbar` (with a `Thumb` directly inside its track) per axis, and a `Corner` when you show both. ```tsx title="ScrollArea anatomy" {/* content */} ``` | Part | Required | Responsibility | | --- | --- | --- | | `ScrollArea.Root` | yes | Holds the viewport ref, tracks per-axis metrics, and computes scrollbar visibility from overflow and type. Renders only context, no instance. | | `ScrollArea.Viewport` | yes | The `ScrollingFrame` that scrolls content and reports its canvas/window sizes back to `Root`. | | `ScrollArea.Scrollbar` | no | A track for one axis; a press on empty track jumps the canvas so the thumb centers on the press. | | `ScrollArea.Thumb` | no | The draggable handle inside a scrollbar; sized and positioned to the scroll ratio. | | `ScrollArea.Corner` | no | Fills the intersection square, shown only when both scrollbars are visible. | ## Examples ### Basic vertical list The smallest useful composition: the default viewport (a 260x160 `scrollingframe`), the default scrollbar track (8px wide, pinned to the right), and the default rounded thumb. Because `Root` renders no instance, the parts sit in a plain container frame you provide. Wheel and touch scrolling work immediately; the scrollbar appears only once the list actually overflows. ```tsx title="PatchNotes.tsx" import { ScrollArea } from "@lattice-ui/react-scroll-area"; export function PatchNotes(props: { lines: string[] }) { return ( {props.lines.map((line, index) => ( ))} ); } ``` ### Horizontal scroll row A single row that overflows sideways — hotbars, category chips, cosmetic carousels. Pass `orientation="horizontal"` to both `Scrollbar` and `Thumb`, and use `asChild` on the viewport and scrollbar to size them into your layout: the viewport fills the container minus the track height, and the track pins to the bottom edge. ```tsx title="CosmeticRow.tsx" import { ScrollArea } from "@lattice-ui/react-scroll-area"; export function CosmeticRow(props: { cosmetics: string[] }) { return ( {props.cosmetics.map((cosmetic) => ( ))} ); } ``` ### Both axes with a corner Content that overflows in both directions — maps, skill trees, wide tables. Provide one scrollbar/thumb pair per axis and a `Corner` for the square where the two tracks would overlap; the corner shows itself only while both scrollbars are visible. Each track is shortened by the other track's thickness so they meet at the corner instead of crossing. ```tsx title="WorldMap.tsx" import { ScrollArea } from "@lattice-ui/react-scroll-area"; export function WorldMap() { return ( ); } ``` ### Scrollbar visibility modes `type` controls when visible scrollbars appear. `"auto"` (the default) and `"always"` show a scrollbar while its axis overflows and keep it on screen. `"scroll"` is the transient mode: the bar appears on scroll activity and fades out after `scrollHideDelayMs` of inactivity — a good fit for chat logs and feeds where a permanent track is visual noise. In every mode, an axis with no overflow shows no scrollbar at all. ```tsx title="ChatLog.tsx" import { ScrollArea } from "@lattice-ui/react-scroll-area"; export function ChatLog(props: { messages: string[] }) { return ( {props.messages.map((message, index) => ( ))} ); } ``` ### Custom track and thumb with asChild Use `asChild` on `Scrollbar` and `Thumb` when you need a different element class than the frames they render. The scrollbar's Slot projects visibility and track-press handling onto your track element; the thumb's Slot projects its computed `Position` and `Size` (both scale-based) plus drag handling onto your handle. Do not set `Position` or `Size` on the thumb element — the primitive owns them — and style it with corners, strokes, and colors instead. Keep the `Thumb` a direct child of the track element: the thumb measures its parent to map drags to canvas positions. ```tsx title="QuestLog.tsx" import { ScrollArea } from "@lattice-ui/react-scroll-area"; export function QuestLog(props: { quests: string[] }) { return ( {props.quests.map((quest) => ( ))} ); } ``` ## How it behaves ### Scrolling and metrics `ScrollArea.Viewport` renders a `ScrollingFrame` with `AutomaticCanvasSize` on both axes, `ScrollingDirection` set to XY, and the native scrollbars hidden (`ScrollBarThickness = 0`, fully transparent image). It registers the frame with `Root` and listens to `CanvasPosition`, `AbsoluteCanvasSize`, and `AbsoluteWindowSize` changes, pushing the per-axis `viewportSize`, `contentSize`, and `scrollPosition` into context on every change (with an equality bail-out so identical measurements never re-render). Because the underlying control is a real `ScrollingFrame`, mouse-wheel, touch-drag, and momentum scrolling all work natively — the primitive layers custom scrollbars on top of that. The default viewport is a fixed 260x160 frame; use `asChild` to supply your own `scrollingframe` sized to your layout. ### Scrollbar visibility `Root` derives overflow per axis — content larger than the viewport, with a 1px tolerance — and resolves visibility from `type`: - `"auto"` (default) — show a scrollbar while that axis overflows. - `"always"` — show whenever the axis overflows; never auto-hide. - `"scroll"` — show on scroll activity, then auto-hide after `scrollHideDelayMs` (default `600`ms, floored at 0) of inactivity, only while overflowing. For `type="scroll"`, any `CanvasPosition` change — native scrolling, a thumb drag, or a track press — counts as activity and restarts the hide timer. `Scrollbar` and `Corner` read the resolved flags and toggle their own `Visible` (and `Active`, so a hidden track ignores input). `Corner` shows only when both the vertical and horizontal scrollbars are visible. ### Thumb sizing and position `ScrollArea.Thumb` computes its length as the viewport-to-content ratio of the track, clamped to a minimum of 18px so it stays grabbable against very long content, and its offset tracks the current scroll position. Both are applied as scale values — full track thickness across, `viewport/content` of the track along the axis — so the same thumb works on any track size. The rendered scale assumes the track spans the viewport's length on that axis; drags and track presses always re-measure the track's real `AbsoluteSize`, so interaction stays exact even when your track is inset like the QuestLog example above. ### Dragging and track presses Pressing the thumb (mouse button or touch) starts a drag: pointer movement is tracked globally through `UserInputService`, the delta is mapped from thumb offset to a new `CanvasPosition`, and releasing the pointer ends the drag. If your thumb is a `GuiButton`, its `AutoButtonColor` is switched off on drag start so it does not flash while dragging. Pressing the empty `Scrollbar` track (outside the thumb) jumps the canvas so the thumb centers on the pressed point; presses on the thumb region are left to the thumb's own drag handling. All scroll changes flow through `Root.setScrollPosition`, which clamps to `[0, contentSize - viewportSize]` and writes the viewport's `CanvasPosition` — the native frame stays the single source of truth. ### Orientation and composition `Scrollbar` and `Thumb` each require an `orientation` of `"vertical"` or `"horizontal"`, and a thumb should match the scrollbar it sits in. The default rendered scrollbar pins to the right edge (vertical) or bottom edge (horizontal) at an 8px thickness sized to the 260x160 default viewport; for any other layout, pass `asChild` with your own positioned track. Provide one scrollbar/thumb pair per axis you want to expose, and add a `Corner` when both are present. > **Roblox scrolling** > > The viewport is a native `ScrollingFrame` and remains the source of truth for `CanvasPosition`. Lattice-UI only hides the built-in scrollbars and reflects the canvas metrics into your custom parts — it never replaces native scroll input, so wheel and touch scrolling keep working even without a `Scrollbar`. > **Sizing the viewport** > > Give the viewport (or its container) a concrete size. The default `Viewport` falls back to a fixed 260x160 offset size; in practice you will pass `asChild` with a `scrollingframe` sized to fill its parent, as in the examples, so overflow and thumb ratios compute against the real layout. > **Keep the thumb directly inside its track** > > The thumb finds its track by reading its parent instance — that parent's `AbsoluteSize` and `AbsolutePosition` drive all drag and press math. Whether you use the default scrollbar or `asChild`, render `Thumb` as a direct child of the track element, with no wrapper frames in between. ## API reference ### ScrollArea.Root | Prop | Type | Description | | --- | --- | --- | | `type` | `"auto" \| "always" \| "scroll"` | Scrollbar visibility strategy. Defaults to "auto". | | `scrollHideDelayMs` | `number` | For type="scroll", how long after activity before scrollbars auto-hide. Defaults to 600; floored at 0. | | `children` | `React.ReactNode` | Viewport, scrollbars, thumbs, and corner. Root renders no instance of its own. | ### ScrollArea.Viewport | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Render your own scrollingframe instead of the default 260x160 one; the scroll props and metrics ref are projected onto it. | | `children` | `React.ReactElement` | With asChild, the scrollingframe element; otherwise the content placed inside the default viewport. | ### ScrollArea.Scrollbar | Prop | Type | Description | | --- | --- | --- | | `orientation` | `"vertical" \| "horizontal"` | Which axis this scrollbar controls. Required. | | `asChild` | `boolean` | Render your own track element via Slot; visibility and track-press handling are projected onto it. | | `children` | `React.ReactElement` | With asChild, the track element; otherwise content placed inside the default track, typically a Thumb. | ### ScrollArea.Thumb | Prop | Type | Description | | --- | --- | --- | | `orientation` | `"vertical" \| "horizontal"` | Which axis this thumb belongs to. Required; should match its scrollbar. | | `asChild` | `boolean` | Render your own thumb element via Slot; the computed Position, Size, and drag handling are projected onto it, overriding your own. | | `children` | `React.ReactElement` | With asChild, the thumb element; otherwise content placed inside the default thumb. | ### ScrollArea.Corner | Prop | Type | Description | | --- | --- | --- | | `asChild` | `boolean` | Render your own corner element via Slot; its visibility is bound to both scrollbars being shown. | | `children` | `React.ReactElement` | With asChild, the corner element; otherwise content placed inside the default corner. | ## Related - [asChild composition](https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition.md) - [Controllable state](https://docs.astra-void.xyz/lattice-ui/guides/controlled-state.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) --- # Focus management > How lattice-ui keeps GuiObject selection predictable across overlays — focus scopes, focus nodes, restore snapshots, and ordered selection. Source: https://docs.astra-void.xyz/lattice-ui/guides/focus-management/ On Roblox, "focus" is gamepad and keyboard **selection**: a single `GuiObject` held by `GuiService.SelectedObject`. There is no DOM focus tree, no tab order, and no implicit notion of "the thing the user is on" beyond that one property. The moment you start layering surfaces — a dialog over a menu, a popover inside a panel — that single property becomes a contended resource. Open a modal and selection should move into it; close the modal and selection should land back on whatever opened it; while it is open, the player's gamepad should not be able to wander out of it. `@lattice-ui/react-focus` is the foundation that makes this deterministic. It maintains its own model of **focus scopes** and **focus nodes**, decides which node should be selected, and bridges that decision to `GuiService.SelectedObject`. Every layered primitive in lattice-ui — Dialog, Menu, Popover, Select, and the rest — sits on top of it, so trapping and restoring selection works the same way everywhere. You rarely call this package directly when you compose primitives — Dialog and friends wire it for you. Reach for it when you are **building your own selectable surface** or need to drive selection by hand. ## Import ```ts import { FocusScope, useFocusNode, focusGuiObject, focusNode, getFocusedGuiObject, captureRestoreSnapshot, restoreSnapshot, } from "@lattice-ui/react-focus"; ``` ## The model: scopes and nodes Two registrations drive everything. - A **focus node** is a selectable `GuiObject` the manager is allowed to select. You register one with `useFocusNode`, handing it a ref to the instance. The manager only ever selects a registered node (or an *implicit* node it infers when selection arrives from outside). - A **focus scope** is a region — a dialog body, a menu list — with rules about activity, trapping, and restoration. `FocusScope` registers one and shares its id through context so any `useFocusNode` rendered inside is owned by that scope. The manager keeps both in ordered lists, resolves which node is currently focusable, and writes the result to `GuiService.SelectedObject`. When the selected object changes from outside (the player moves the gamepad), it reads it back and re-resolves. That two-way bridge is the whole point: your model and Roblox's selection stay in agreement. ### Registering a focusable node `useFocusNode` takes a ref to the `GuiObject` and registers it under the nearest scope. It returns a ref to the node id, which you generally do not need. ```tsx title="SelectableTile.tsx" import { React } from "@rbxts/react"; import { useFocusNode } from "@lattice-ui/react-focus"; export function SelectableTile(props: { label: string; disabled?: boolean }) { const tileRef = React.useRef(); useFocusNode({ ref: tileRef, getDisabled: () => props.disabled === true, }); return ( ); } ``` > **Selectable is the source of truth** > > The manager will never select a node whose `GuiObject.Selectable` is `false`, whose `Visible` chain is broken, or whose `getDisabled()` returns `true`. Keep `Selectable` and `Active` in sync with your disabled state — `getDisabled` mirrors it in the model, but `Selectable` is what Roblox enforces. ### Defining a scope Wrap a region in `FocusScope` to make it a focus boundary. By default a scope is `active`, does **not** trap, and **does** restore focus on deactivation. ```tsx title="Panel.tsx" import { FocusScope } from "@lattice-ui/react-focus"; export function Panel(props: { open: boolean; children: React.ReactNode }) { return ( {props.children} ); } ``` Use `asChild` when you want the scope root to *be* your frame instead of an extra wrapper — the child element receives the scope's ref: ```tsx {props.children} ``` | Prop | Type | Description | | --- | --- | --- | | `active` | `boolean` | Whether the scope is participating. Defaults to true. Flipping to false triggers restore. | | `trapped` | `boolean` | Keeps selection inside this scope while it is the topmost trapped scope. Defaults to false. | | `restoreFocus` | `boolean` | Restores selection to the previously selected object when the scope deactivates. Defaults to true. | | `asChild` | `boolean` | Use the single child element as the scope root instead of rendering a wrapper frame. | | `children` | `React.ReactNode` | The scope contents. | ## Trapping selection A **trapped** scope keeps gamepad and keyboard selection inside it. While a trapped scope is active, the manager refuses to resolve any node outside its root: if the player moves selection out — or external code sets `SelectedObject` to something outside — selection is pulled back to a focusable node inside the scope. ```tsx title="ModalSurface.tsx" import { FocusScope } from "@lattice-ui/react-focus"; export function ModalSurface(props: { open: boolean; children: React.ReactNode }) { // While open, selection cannot leave this frame. return ( {props.children} ); } ``` Trapping is **layer-aware**, not a stack of mutually exclusive locks. When several trapped scopes are active, the manager picks the *topmost* one — first by `FocusLayerProvider` layer order, then by registration order — and traps within it. Closing the top scope hands the trap to the next one down. This is exactly what lets a Popover open inside a Dialog without either fighting the other. > **Provide a focusable target before trapping** > > A trapped scope needs something to hold. On activation the manager looks for the scope's last-focused node, then the first focusable descendant. If the scope has no selectable `GuiObject` yet — for example because its content mounts a frame later — selection clears until one appears. Make sure at least one child is `Selectable` by the time the scope becomes active. ## Restoring selection When a scope with `restoreFocus` activates, it captures a **restore snapshot**: the node (or raw `GuiObject`) that was selected just before. When that scope deactivates, the manager replays the snapshot, returning selection to whatever opened the surface — typically the trigger. Restoration degrades gracefully. If the snapshot node is gone (unmounted, now invisible, or disabled), the manager walks up to the parent scope and selects its best fallback, then falls back to the topmost remaining trapped scope. It never leaves selection pointing at a dead instance. You can drive this yourself for surfaces you build outside the primitives: ```tsx title="useRestoreOnClose.ts" import { React } from "@rbxts/react"; import { captureRestoreSnapshot, restoreSnapshot, type FocusRestoreSnapshot, } from "@lattice-ui/react-focus"; export function useRestoreOnClose(open: boolean) { const snapshotRef = React.useRef(); React.useEffect(() => { if (open) { // Remember what was selected before the surface took over. snapshotRef.current = captureRestoreSnapshot(); return; } // On close, hand selection back. restoreSnapshot(snapshotRef.current); snapshotRef.current = undefined; }, [open]); } ``` > **Snapshots reference nodes, not raw selection** > > `captureRestoreSnapshot` records the focus node id, not the current `GuiObject` directly. If nothing is focused in the model, it falls back to an implicit node inferred from `GuiService.SelectedObject`. Capture *before* you change selection — once your surface has stolen it, the snapshot would point at your own content. ## Moving selection by hand Sometimes you need to put selection somewhere explicitly — the first item of a freshly opened list, a confirm button, a recovered field after validation. The manager exposes imperative helpers: - `focusGuiObject(guiObject)` — select a specific `GuiObject` (registering it implicitly if needed). Returns the object that ended up selected, or `undefined` if it was not focusable. - `focusNode(nodeId)` — select a known node by id. - `getFocusedGuiObject()` — read the currently focused object from the model. - `clearFocus()` — clear selection. ```tsx title="autoFocusFirst.ts" import { focusGuiObject } from "@lattice-ui/react-focus"; export function autoFocusFirst(firstItem: GuiObject | undefined) { // Defer a frame so the instance is parented and visible before selecting. task.defer(() => { focusGuiObject(firstItem); }); } ``` > **Why defer** > > A `GuiObject` is not focusable until it is parented and effectively visible. Right after mount the instance may not satisfy that yet, so `focusGuiObject` returns `undefined`. Deferring one frame (or marking the scope active and letting trap enforcement pick it up) is the reliable path. ## Ordered selection Grids, menus, and radio groups need **directional movement** — "next item", "previous item" — that skips disabled and hidden entries and wraps predictably. The ordered-selection helpers operate on a list of entries you maintain (each with an `id`, an `order`, and a ref) and resolve which one to move to. ```tsx title="useArrowNavigation.ts" import { getCurrentOrderedSelectionEntry, getRelativeOrderedSelectionEntry, focusOrderedSelectionEntry, type OrderedSelectionEntry, type OrderedSelectionDirection, } from "@lattice-ui/react-focus"; export function moveSelection( entries: Array, direction: OrderedSelectionDirection, ) { const current = getCurrentOrderedSelectionEntry(entries); const next = getRelativeOrderedSelectionEntry(entries, current?.id, direction); focusOrderedSelectionEntry(next); } ``` Key behaviors to rely on: - Entries are sorted by their `order` field, not array position, so registration order does not have to match visual order. - `getRelativeOrderedSelectionEntry` filters out unavailable entries (not present, disabled, hidden, or not `Selectable`) before stepping, and **clamps** at the ends — it does not wrap. Add your own wrap if you want it. - With no current selection, a `+1` direction lands on the first available entry and `-1` on the last. > **Direction is a fixed type** > > `OrderedSelectionDirection` is `-1 | 1`, not an arbitrary number. Map your gamepad/arrow input to one of those two values — for a horizontal group, `Left` is `-1` and `Right` is `1`; for vertical, `Up`/`Down`. ## How primitives wire all of this When you use Dialog or Menu, the wiring above is already done. `Dialog.Content` renders a `FocusScope` with `trapFocus` and `restoreFocus`; `Dialog.Trigger` registers a focus node so it is the natural restore target. You only reach for `@lattice-ui/react-focus` directly when building a selectable surface the primitives do not cover. ```tsx title="Reading what a primitive already does" // Equivalent to what Dialog.Content sets up for you: {/* trigger captured the restore snapshot when this activated */} {children} ``` ## Related - [Dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) - [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) - [Popover](https://docs.astra-void.xyz/lattice-ui/components/popover.md) - [Select](https://docs.astra-void.xyz/lattice-ui/components/select.md) - [Radio group](https://docs.astra-void.xyz/lattice-ui/components/radio-group.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Focus reference](https://docs.astra-void.xyz/lattice-ui/reference/focus.md) --- # Gamepad and input > How lattice-ui serves pointer, touch, keyboard, and gamepad players with one input model — ordered selection, engine-driven gamepad navigation, and per-item key handling. Source: https://docs.astra-void.xyz/lattice-ui/guides/gamepad-and-input/ A Roblox experience is played with whatever is in the player's hands: a mouse, a touchscreen, a keyboard, a gamepad — often several in the same session. lattice-ui primitives are built so one composition serves all of them. Pointer and touch activate controls directly; keyboard arrows move through items in a deterministic order; gamepad navigation rides Roblox's selection engine, with the [focus manager](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) keeping it inside the right surface. This guide covers the input side of that story — how movement and activation actually reach your components. For scopes, trapping, and restore, see [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md). ## One item, three input classes Every interactive lattice item — a tab trigger, a radio item, a menu item — wires the same three Roblox event surfaces: - **`Activated`** — fires on mouse click, touch tap, and gamepad A. This is the "press" path for pointers and gamepads. - **`InputBegan`** — receives keyboard input while the item holds selection. This is where arrow-key movement and Enter/Space activation are handled. - **`SelectionGained` / `SelectionLost`** — fire when `GuiService.SelectedObject` lands on or leaves the item, whether the engine moved it (gamepad) or lattice did (keyboard, imperative focus). This is where selection visuals and selection-follows-focus behavior live. ```tsx title="The event wiring every lattice item uses" const eventHandlers = React.useMemo( () => ({ Activated: handleActivated, // click, tap, gamepad A InputBegan: handleInputBegan, // arrows, Enter, Space while selected SelectionGained: handleSelectionGained, // selection arrived here }), [handleActivated, handleInputBegan, handleSelectionGained], ); return ; ``` ## The ordered-selection model Composite widgets need "next item" and "previous item" to mean something stable, not "whatever is geometrically nearby". Each item registers an **ordered-selection entry** with its root — `{ id, order, ref, getDisabled?, getVisible? }` — and the root resolves movement with the helpers from `@lattice-ui/react-focus`: - `getOrderedSelectionEntries` sorts entries by their `order` field, so movement order is declaration order, not render or array order. - An entry is **available** only if its `GuiObject` exists, is not disabled, is `Visible` (and `getVisible()` does not veto), and has `Selectable` set. Unavailable entries are skipped entirely. - `getRelativeOrderedSelectionEntry(entries, currentId, direction)` steps `-1` or `+1` through the available entries and **clamps at the ends** — it does not wrap. With no current entry, `+1` resolves the first available item and `-1` the last. - `focusOrderedSelectionEntry(entry)` hands the result to the focus manager, which selects the underlying `GuiObject`. This is exactly what Tabs, RadioGroup, and Menu do internally. Their roots keep a registry; each item reports movement, and the root focuses the resolved neighbor: ```tsx title="How Menu resolves Up/Down (from MenuRoot)" const moveSelection = React.useCallback((direction: -1 | 1) => { const currentItem = getCurrentOrderedSelectionEntry(itemEntriesRef.current); const nextItem = getRelativeOrderedSelectionEntry(itemEntriesRef.current, currentItem?.id, direction); focusOrderedSelectionEntry(nextItem); }, []); ``` > **Movement never lands on a dead item** > > Because availability is checked at move time — live refs, live `getDisabled`, live `Visible` — a disabled or hidden item is skipped without any re-registration. You never have to rebuild the registry when an item's state changes. ## Gamepad selection On gamepad, lattice does **not** intercept the thumbstick or d-pad. Movement between selectable objects is Roblox's own directional navigation: the engine walks `GuiService.SelectedObject` between `GuiObject`s whose `Selectable` property is `true`, using on-screen geometry, and draws its default selection ring around the result. lattice-ui does not set `SelectionGroup` or replace `SelectionImageObject` — what it does instead: - **Primitives keep `Selectable` truthful.** Every item renders with `Selectable={!disabled}` (and `Active={!disabled}`), so the engine can only land on things your logic considers interactive. - **The focus bridge reads movement back.** While any `FocusScope` is active, the manager listens to `GuiService.SelectedObject` changes. When the engine moves selection, the model updates to match — and if a trapped scope is active and selection escaped it, the manager pulls selection back to a focusable node inside. See [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) for the trap rules. - **`SelectionGained` drives your visuals and state.** Tabs and RadioGroup select their value the moment selection lands on an item ("selection follows focus"), and Menu highlights the selected item with the same handler it uses for `MouseEnter`. ```tsx title="Selection visuals ride SelectionGained (MenuItem pattern)" const handlePointerEnter = React.useCallback(() => setActive(true), []); const handlePointerLeave = React.useCallback(() => setActive(false), []); const eventHandlers = React.useMemo( () => ({ Activated: handleActivated, MouseEnter: handlePointerEnter, MouseLeave: handlePointerLeave, SelectionGained: handlePointerEnter, // gamepad focus looks like hover SelectionLost: handlePointerLeave, }), [handleActivated, handlePointerEnter, handlePointerLeave], ); ``` Activation on gamepad is the engine's job too: pressing A on the selected object fires `Activated`, the same handler a click or tap runs. You do not write gamepad-specific activation code. ## Keyboard Keyboard input reaches the item that currently holds selection through `InputBegan`. Each composite maps arrow `KeyCode`s to an ordered-selection move, and Enter (`Return`) or Space to activation: | Component | Movement keys | Activation | | --- | --- | --- | | `Tabs.Trigger` | / when `orientation="horizontal"`, / when vertical | Enter, Space — and selecting a trigger activates it | | `RadioGroup.Item` | / or / per `orientation` (default vertical) | Enter, Space — moving also selects the landed item | | `Menu.Item` | / | Enter, Space | Unlike gamepad movement, keyboard movement goes through the ordered-selection helpers — it follows declaration order, skips disabled and hidden items, and clamps at the ends rather than wrapping. `Menu.Trigger` also opens on Enter/Space, after which the menu focuses its first available item so arrows work immediately. ## Pointer and touch Pointers need the one thing selection does not: **dismissal by pressing elsewhere**. `@lattice-ui/react-layer`'s dismissable stack listens to `UserInputService.InputBegan` and treats exactly two input types as pointers — `MouseButton1` and `Touch`. A press that is outside the topmost enabled layer's content (hit-tested with `GetGuiObjectsAtPosition`, with inset-compensated sample points) fires `onPointerDownOutside` and `onInteractOutside`, then dismisses the layer unless a handler calls `preventDefault()`. Input the engine already consumed (`gameProcessedEvent`) is ignored, and only the topmost layer reacts — nested surfaces dismiss one at a time. ```tsx title="Keeping a surface open on outside press" { // e.g. presses on the anchor toolbar should not dismiss event.preventDefault(); }} /> ``` Touch has no hover, so do not gate anything important behind `MouseEnter` alone — the `SelectionGained`-as-hover pattern above means gamepad players get the highlight, and touch players see state change on tap. For how layers stack and where portalled surfaces live, see [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md). ## What `disabled` actually does Disabling an item does not unregister anything — it flips live getters that every path checks at use time: - The rendered `textbutton` gets `Selectable={false}` and `Active={false}`, so the **engine's** gamepad navigation skips it and it stops firing `Activated`. - The item's focus node reports `getDisabled() === true`, so the **focus manager** refuses to resolve it — it cannot be focused imperatively, used as a trap fallback, or restored to. - Its ordered-selection entry becomes unavailable, so **keyboard movement** steps over it as if it were not there. - The item's own handlers early-return, so stray input while it disables mid-frame does nothing. Keep these in agreement in your own composites: `Selectable` is what Roblox enforces, `getDisabled` is what the model enforces. The [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) guide covers what goes wrong when they diverge. ## Example: a gamepad-friendly radio group Everything above composes for free — this settings group is fully drivable by click, tap, arrows, and gamepad. The only input-specific work left to you is **sizing**: give each row enough height to be a comfortable touch target and a legible selection-ring stop (36–44 px works well). ```tsx title="QualityPicker.tsx" import { React } from "@rbxts/react"; import { RadioGroup } from "@lattice-ui/react-radio-group"; const OPTIONS = ["low", "medium", "high", "ultra"]; export function QualityPicker(props: { value: string; onChange: (value: string) => void }) { return ( {OPTIONS.map((option, index) => ( {/* 40px rows: easy touch target, clear gamepad ring stop */} ))} ); } ``` Pressing on "medium" focuses and selects "high"; flicking the gamepad stick does the same through the engine; tapping any row selects it directly. Disable an option and every input mode skips it. ## Related - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Roblox UI constraints](https://docs.astra-void.xyz/lattice-ui/guides/roblox-ui-constraints.md) - [Portals and layers](https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers.md) - [Tabs](https://docs.astra-void.xyz/lattice-ui/components/tabs.md) - [Radio group](https://docs.astra-void.xyz/lattice-ui/components/radio-group.md) - [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) - [Focus reference](https://docs.astra-void.xyz/lattice-ui/reference/focus.md) --- # Controlled and uncontrolled state > Understand the controlled/uncontrolled pattern that every stateful Lattice primitive shares, and when to own the state yourself versus letting the primitive own it. Source: https://docs.astra-void.xyz/lattice-ui/guides/controlled-state/ A `Dialog` has open state. `Tabs` has a selected value. A `Checkbox` has a checked flag. Every stateful primitive in Lattice has to answer one question: *who owns this state — the primitive, or the app?* Rather than forcing one answer, each primitive supports both modes through the same prop shape, so you can start simple and reach for control only when you need it. This pattern is implemented once, in `useControllableState` from `@lattice-ui/react-runtime`, and every primitive root uses it. Learn the shape once and it applies everywhere. ## The two modes Every controllable prop comes as a trio: a controlled value, a default for uncontrolled use, and a change callback. - **Uncontrolled** — pass only the `default*` prop. The primitive owns the state internally; you read changes through the callback if you care. - **Controlled** — pass the value prop *and* the change callback. Now the app owns the state; the primitive renders whatever you give it and asks you to update via the callback. The naming is consistent across the library: | Concept | Controlled | Uncontrolled default | Change callback | | --- | --- | --- | --- | | Open state (overlays) | `open` | `defaultOpen` | `onOpenChange` | | Selection value | `value` | `defaultValue` | `onValueChange` | | Boolean state | `checked` | `defaultChecked` | `onCheckedChange` | Some field primitives also expose a commit callback (for example `onValueCommit`) that fires only when the value is finalized, separate from the per-change callback. ## Uncontrolled: let the primitive own it This is the default, and it is the right choice most of the time. You give an initial value and forget about it. ```tsx title="Uncontrolled dialog" import { Dialog } from "@lattice-ui/react-dialog"; export function HelpDialog() { return ( {/* The dialog opens and closes itself. */} ); } ``` The trigger opens it, the close button (or an outside press) closes it, and you never touched the state. `defaultOpen` defaults to `false`, so you can usually omit it entirely. ## Controlled: let the app own it Pass `open` and `onOpenChange` together when something *outside* the primitive needs to read or drive the state. ```tsx title="Controlled dialog" import { useState } from "@rbxts/react"; import { Dialog } from "@lattice-ui/react-dialog"; export function ConfirmPurchaseDialog(props: { productId: string }) { const [open, setOpen] = useState(false); // Open it in response to something elsewhere in the app. // The dialog never opens or closes unless `open` says so. return ( {/* ... */} ); } ``` In controlled mode the primitive does **not** update its own state. When the user clicks the trigger it calls `onOpenChange(true)` and waits for you to feed `open={true}` back in. If you ignore the callback, nothing happens — which is exactly the leverage controlled mode gives you (you can veto, gate, or defer the change). The same shape applies to selection primitives: ```tsx title="Controlled tabs" import { useState } from "@rbxts/react"; import { Tabs } from "@lattice-ui/react-tabs"; export function ProfilePanels() { const [tab, setTab] = useState("overview"); return ( {/* ...triggers and panels... */} ); } ``` ## How it behaves under the hood `useControllableState` decides which mode it is in by a single rule: **if the value prop is defined, it is controlled; otherwise it is uncontrolled.** That has a few consequences worth knowing: - The change callback fires in **both** modes, so you can observe an uncontrolled primitive without taking it over. - In controlled mode internal state is never written — your prop is the only source of truth, so the rendered state can never drift from app state. - The callback only fires when the value **actually changes**. Setting it to the same value is a no-op and will not call your handler. | Prop | Type | Description | | --- | --- | --- | | `value (open/checked)` | `T \| undefined` | When defined, the primitive is controlled and renders exactly this value. | | `defaultValue (defaultOpen/...)` | `T` | Initial value for uncontrolled mode. Ignored once a controlled value is supplied. | | `onChange (onOpenChange/...)` | `(next: T) => void` | Called with the next value whenever it changes, in both modes. | > **Don't switch modes mid-life** > > Pick controlled or uncontrolled and stay there for the life of the component. Flipping `open` between `undefined` and a real boolean across renders makes the primitive switch ownership models and produces confusing state. If you need control conditionally, always pass a defined value and a callback. > **Controlled means you must apply the change** > > A common mistake is passing `open` without `onOpenChange`, or passing the callback but never updating the state. The primitive will appear frozen — the trigger fires the callback but `open` never changes, so nothing opens. In controlled mode, wiring the callback back into the value is mandatory. ## When to control state - Multiple surfaces or systems need to **coordinate** — opening one panel closes another, or app logic decides what is selected. - You need to **gate or veto** a change (only open if the player has permission; confirm before switching tabs). - The state must be **persisted, restored, or synced** from outside the component tree. ## When to leave it uncontrolled - The interaction is **self-contained** — a help dialog, a local accordion, a standalone toggle. - Nothing outside the primitive needs to know or influence the value. Uncontrolled is less code and harder to get wrong. Reach for controlled only when the app genuinely needs the leverage. ## Related - [Composition model](https://docs.astra-void.xyz/lattice-ui/getting-started/composition-model.md) - [Dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) - [Tabs](https://docs.astra-void.xyz/lattice-ui/components/tabs.md) - [Checkbox](https://docs.astra-void.xyz/lattice-ui/components/checkbox.md) - [Runtime reference](https://docs.astra-void.xyz/lattice-ui/reference/runtime.md) --- # Portals and layers > Render overlays into the right ScreenGui, order them predictably with DisplayOrder, and coordinate outside-press dismissal — without hard-coding layout math. Source: https://docs.astra-void.xyz/lattice-ui/guides/portals-and-layers/ An overlay has a layout problem the moment it is born inside another component. A tooltip rendered inside a scrolling list gets clipped by the list. A dialog rendered deep in a panel inherits that panel's `ZIndex` band and ends up fighting siblings for stacking order. The content needs to *escape* its parent and render at the top of the screen tree — while its trigger stays where it logically belongs. `@lattice-ui/react-layer` provides the three pieces that make this work: **Portal** moves content into the right `BasePlayerGui`, **DismissableLayer** gives each surface its own ordered `ScreenGui` and coordinates outside-press dismissal, and **Presence** keeps a node mounted long enough to animate out. Every overlay primitive — Dialog, Popover, Menu, Select, Tooltip — is built on these. ## Install The shipped components depend on this package already. Install it directly only when building a custom layered surface. ```bash pnpm add @lattice-ui/react-layer ``` ## Portals: escaping the local tree `createPortal` (re-exported through `Portal`) renders children into a target `Instance` instead of the position where the element appears in the tree. The trigger stays in your component; the content renders elsewhere. ```tsx title="Portal escapes clipping and stacking" import { Popover } from "@lattice-ui/react-popover"; export function HelpPopover() { return ( {/* This trigger can live inside a clipped, scrolling list... */} {/* ...but the content portals out to the top of the screen tree. */} ); } ``` The portal target resolves from the nearest `PortalProvider`, or from an explicit `container` prop on the portal part. Portaling is a **behavior** decision, not a styling one: you portal because the content must escape clipping or stacking, not to change how it looks. ### Setting the portal target with `PortalProvider` `PortalProvider` supplies the default `container` and `displayOrderBase` for every layer beneath it through context. Mount it once near the root of your UI with the player's `PlayerGui`. ```tsx title="App root" import { Players } from "@rbxts/services"; import { PortalProvider } from "@lattice-ui/react-layer"; const playerGui = Players.LocalPlayer.WaitForChild("PlayerGui") as PlayerGui; export function App() { return ( {/* All overlays below portal into playerGui and order from 1000 up. */} ); } ``` | Prop | Type | Description | | --- | --- | --- | | `container` | `BasePlayerGui` | The PlayerGui (or PluginGui) every layer below portals into by default. | | `displayOrderBase` | `number` | Base DisplayOrder for generated ScreenGuis. Defaults to 1000. | | `children` | `React.ReactNode` | Your app tree. | > **Keep the container stable** > > Resolve the `PlayerGui` once and pass the same `container` for the life of the surface. Swapping the portal target mid-life tears down and re-mounts the surface — losing focus, motion, and any in-progress interaction. Treat the container as fixed infrastructure. ## Layers: ordering and outside dismissal `DismissableLayer` is what each overlay's content actually mounts inside. Every layer creates its own `ScreenGui` and: - **Assigns a `DisplayOrder`** of `displayOrderBase + mountOrder`, so layers stack in the order they opened — the most recently opened surface sits on top. - **Tracks a layer stack** so dismissal is ordered: an outside press dismisses the *topmost* layer, not all of them at once. Nested overlays close from the inside out. - **Optionally blocks interaction behind it** when `modal` (or `disableOutsidePointerEvents`) is set, by rendering a full-screen `Modal` `textbutton` behind the content. - **Reports outside interactions** so you can observe — or veto — them before the surface dismisses. ```tsx title="A custom dismissable surface" import React from "@rbxts/react"; import { DismissableLayer } from "@lattice-ui/react-layer"; function QuickPanel(props: { open: boolean; onClose: () => void }) { const boundaryRef = React.useRef(); if (!props.open) { return undefined; } return ( { // Veto dismissal for a specific region if needed. // event.preventDefault(); print("pressed outside", event.originalEvent.UserInputType); }} > ); } ``` | Prop | Type | Description | | --- | --- | --- | | `enabled` | `boolean` | Whether the layer participates in the stack and dismissal. Defaults to true. | | `modal` | `boolean` | Blocks pointer interaction behind the layer and enables outside-press dismissal. | | `disableOutsidePointerEvents` | `boolean` | Blocks outside pointer events without the full modal semantics. | | `contentBoundaryRef` | `React.MutableRefObject` | Marks the node whose bounds define 'inside'. Presses inside it are not outside presses. | | `insideRefs` | `Array>` | Extra nodes (e.g. the trigger) that also count as inside. | | `onPointerDownOutside` | `(event: LayerInteractEvent) => void` | Pointer press outside the boundary, before dismissal. Call event.preventDefault() to veto. | | `onInteractOutside` | `(event: LayerInteractEvent) => void` | Any other outside interaction, before dismissal. | | `onDismiss` | `() => void` | Called when the layer should dismiss. | > **DisplayOrder, not ZIndex, decides who wins** > > Because each layer is its own `ScreenGui`, stacking between surfaces is governed by `DisplayOrder`, not by `ZIndex`. `ZIndex` only orders siblings inside a single gui. Give your app's non-Lattice screens a `DisplayOrder` band below `displayOrderBase` (default `1000`) so overlays reliably render above them. ## Nested overlays When overlays stack — a menu opened from inside a dialog, a select inside a popover — the layer stack keeps them honest: - Each new layer mounts with a higher `DisplayOrder`, so the innermost surface is always on top. - An outside press dismisses only the topmost active layer, so closing a menu does not also close the dialog behind it. - The `contentBoundaryRef` and `insideRefs` of an inner layer keep presses on its own content (and trigger) from being treated as "outside". You generally get this for free by composing Lattice primitives. When building custom layers, register the trigger as an inside ref so clicking the trigger to toggle the surface is not misread as an outside dismiss. ## Presence: surviving exit animation When an overlay closes, unmounting it immediately would cut off any exit animation. `Presence` bridges the gap: it keeps the node mounted after `present` flips to `false`, exposes an `isPresent` flag to drive the exit, and only unmounts once the exit completes (or a fallback timeout elapses). ```tsx title="Presence keeps the node mounted to animate out" import { Presence } from "@lattice-ui/react-layer"; ( )} />; ``` `render` receives `{ isPresent, onExitComplete }`. Animate based on `isPresent`, then call `onExitComplete()` when the exit finishes so `Presence` can drop the node. If you never call it, a fallback timer unmounts the node anyway so it can never get stuck mounted. Most components also accept `forceMount` to keep content mounted regardless, which is useful when you drive motion yourself. ## When to portal and layer - The content must **escape clipping or local stacking** — anything floating above the regular layout. - Several surfaces can be **open at once** and need predictable stacking and dismissal. - The surface needs **outside-press dismissal** or modal blocking behind it. ## When not to - The content is **inline** and belongs in the normal layout flow — do not portal it just to reorder it. - A single fixed-position element with no dismissal needs none of this; a plain `ScreenGui` with a set `DisplayOrder` is enough. ## Related - [Dialog](https://docs.astra-void.xyz/lattice-ui/components/dialog.md) - [Popover](https://docs.astra-void.xyz/lattice-ui/components/popover.md) - [Menu](https://docs.astra-void.xyz/lattice-ui/components/menu.md) - [Select](https://docs.astra-void.xyz/lattice-ui/components/select.md) - [Tooltip](https://docs.astra-void.xyz/lattice-ui/components/tooltip.md) - [Focus management](https://docs.astra-void.xyz/lattice-ui/guides/focus-management.md) - [Presence and motion](https://docs.astra-void.xyz/lattice-ui/guides/presence-and-motion.md) - [Positioning with Popper](https://docs.astra-void.xyz/lattice-ui/guides/positioning-with-popper.md) - [Layer reference](https://docs.astra-void.xyz/lattice-ui/reference/layer.md) --- # asChild composition > Merge primitive behavior onto your own host element with asChild and the Slot component, when forwarding props onto the part's own element is not enough. Source: https://docs.astra-void.xyz/lattice-ui/guides/as-child-composition/ Every interactive part of a Lattice primitive has to render *something* — a `Dialog.Trigger` needs a button to click, a `Menu.Item` needs a row to select. Each part renders a host element of a fixed class (usually a `textbutton`) with its behavior wired onto it. `asChild` flips that around. Instead of rendering its own host, the part takes the element *you* provide and merges its behavior — event handlers, refs, and selection flags — onto it. Your element becomes the host; the primitive just enhances it. Under the hood this is powered by the `Slot` component from `@lattice-ui/react-runtime`, which clones your child and composes props onto it. > **You no longer need asChild just to style a part** > > Since 0.7.0 every part forwards unknown props onto the instance it renders, so `` works directly. Reach for `asChild` when you need a **different element class** than the part renders, or when the element comes from somewhere else — not merely to change how it looks. ## The problem `asChild` solves Each part renders one specific instance class. `Popover.Trigger` renders a `textbutton`: ```tsx title="The part's own host element" {/* a textbutton — styled by the props you pass, but still a textbutton */} ``` That class is the primitive's choice, not yours. If your design system already has a `Button` component, or you want an `imagebutton`, no amount of forwarded props will get you there — forwarded props are type-checked against the instance the part renders, so `Image` on a `textbutton` is a compile error. With `asChild`, you hand the part your element and it merges behavior onto it: ```tsx title="Your element, primitive behavior" ``` Now the `imagebutton` *is* the trigger. The primitive composes its `Activated` handler, its ref, and its selection flags onto your element instead of rendering a second node. ## How `Slot` merges props When a part is in `asChild` mode it renders through `Slot`. `Slot` takes exactly one child and clones it, combining the primitive's props with the child's props. The merge rules are important to understand: - **Refs are composed.** Your child's ref and the primitive's ref both fire, so the primitive can measure or focus the node while your own ref still receives it. You never have to choose between them. - **`Event` and `Change` handler tables are chained.** If both the primitive and your child define a handler for the same signal (say `Activated`), both run — the child's handler first, then the primitive's. Neither one clobbers the other. - **Other props are shallow-merged,** with the primitive's props taking precedence for the keys it sets (for example `Active` or `Selectable` on a trigger). ```tsx title="Both Activated handlers run" print("copied"), }} /> ``` Because `Slot` composes rather than replaces, you can attach your own analytics or sound effects to the same event the primitive uses for its behavior. > **asChild takes exactly one GuiObject — plus modifiers** > > `Slot` clones a single child, so the subtree must resolve to exactly one `GuiObject` element. Parts call ``error("... `asChild` requires a child element.")`` when it does not, so a wrong shape fails loudly rather than silently dropping behavior. > > Two things do **not** count against that one-element budget, as of 0.7.0: > > - **Fragments are looked through.** A fragment wrapping a single element resolves to that element. > - **Roblox UI modifiers may sit as siblings.** `uicorner`, `uipadding`, `uilistlayout`, `uistroke`, `uishadow`, `uigradient`, `uiscale`, the constraints, and the rest of the creatable `UIComponent` classes are re-parented under the element the props land on instead of competing to be the target. > > **On 0.7.x that second rule is documented but inert.** The lookup that recognises a modifier was keyed by the JSX tag, and `@rbxts/react` rewrites a host tag to its Roblox class name before it builds the element — `` reaches `Slot` as `"UICorner"` — so every modifier missed it, counted as a second candidate, and `asChild` failed with "expected exactly one child element besides any UI modifiers" on precisely the subtrees the rule was written for. Fixed in 0.8.0. > > Which spelling arrives depends on the renderer, and 0.8.0 recognised only the Roblox one. A browser React renderer builds the element from the tag as written, so `"uicorner"` reaches `Slot` and the same failure appeared there instead — the previews on this site among them. From **0.8.1** the lookup accepts both, and `UIShadow` joins the set it was missing from. > > That second rule exists because Roblox attaches modifiers as *children* rather than properties, which is exactly the shape a Tailwind-style `className` transform emits when it lowers `rounded-md` or `p-2` at the call site — [vela-rbxts](https://docs.astra-void.xyz/vela-rbxts/index.md) being the one this was built against: > > ```tsx title="A modifier sibling is re-parented, not cloned" > > > > > ``` > > Two real `GuiObject` candidates are still an error — the primitive cannot know which one to enhance. > > This is what makes a class name on the *part* work: Vela emits its helper instances as the > component's children, and they arrive here as siblings of your element rather than as rival slot > candidates. [Styling with Vela](https://docs.astra-void.xyz/lattice-ui/guides/styling-with-vela.md) walks through all three > placements. ## Where you'll use it `asChild` shows up on the parts that wrap a single interactive or visual host: - **Triggers** — `Dialog.Trigger`, `Popover.Trigger`, `Select.Trigger`, and friends, so your own button opens the surface. - **Close buttons** — `Dialog.Close`, so a styled button inside the content closes it. - **Items** — `Menu.Item` and similar, so each row is your element. - **Overlays and content hosts** — so the backdrop or panel frame is yours, with the primitive's dismissal/positioning behavior merged on. A trigger that forwards its ref correctly also becomes the focus-restore target and the positioning anchor for free, because the primitive composes its ref onto your element. ```tsx title="A custom Button as a dialog trigger" import { Dialog } from "@lattice-ui/react-dialog"; import { Button } from "../ui/Button"; // your design-system button export function SettingsButton() { return (