@lattice-ui/react-accordionStable directionimport Accordiondepends on runtime, focus, layer, motionAccordion 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.
import { Accordion } from "@lattice-ui/react-accordion";import { React } from "@lattice-ui/react-runtime";import { Text, useTheme } from "@lattice-ui/react-style";
function AccordionExample() { const { theme } = useTheme(); const [open, setOpen] = React.useState("item-1");
const items = [ { value: "item-1", question: "Can I bring my own styles?", answer: "Yes. Every part ships unstyled — style with your own recipes and theme tokens.", }, { value: "item-2", question: "Does it work with gamepads?", answer: "Focus and selection follow Roblox gamepad navigation out of the box.", }, { value: "item-3", question: "Is motion built in?", answer: "Content reveals with a built-in transition from the motion package.", }, ];
return ( <frame BackgroundColor3={theme.colors.surfaceElevated} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)}> <uicorner CornerRadius={new UDim(0, theme.radius.lg)} /> <uistroke Color={theme.colors.border} Thickness={1} /> <uipadding PaddingBottom={new UDim(0, theme.space[8])} PaddingLeft={new UDim(0, theme.space[20])} PaddingRight={new UDim(0, theme.space[20])} PaddingTop={new UDim(0, theme.space[8])} />
<Accordion.Root collapsible onValueChange={(nextValue) => setOpen(typeIs(nextValue, "string") ? nextValue : "")} type="single" value={open} > <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} Size={UDim2.fromOffset(320, 0)}> <uilistlayout FillDirection={Enum.FillDirection.Vertical} /> {items.map((item, index) => ( <Accordion.Item key={item.value} asChild value={item.value}> <frame AutomaticSize={Enum.AutomaticSize.Y} BackgroundTransparency={1} LayoutOrder={index} Size={UDim2.fromOffset(320, 0)} > <uilistlayout FillDirection={Enum.FillDirection.Vertical} /> <Accordion.Header asChild> <frame BackgroundTransparency={1} LayoutOrder={0} Size={UDim2.fromOffset(320, 48)}> <Accordion.Trigger asChild> <textbutton AutoButtonColor={false} BackgroundTransparency={1} Font={Enum.Font.GothamMedium} Size={UDim2.fromScale(1, 1)} Text={item.question} TextColor3={theme.colors.textPrimary} TextSize={theme.typography.labelSm.textSize} TextXAlignment={Enum.TextXAlignment.Left} > <Text AnchorPoint={new Vector2(1, 0.5)} BackgroundTransparency={1} Position={new UDim2(1, 0, 0.5, 0)} Size={UDim2.fromOffset(16, 16)} Text={open === item.value ? "−" : "+"} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.bodyMd.textSize} /> </textbutton> </Accordion.Trigger> </frame> </Accordion.Header> <Accordion.Content asChild> <Text BackgroundTransparency={1} LayoutOrder={1} Size={UDim2.fromOffset(320, 48)} Text={item.answer} TextColor3={theme.colors.textSecondary} TextSize={theme.typography.labelSm.textSize} TextWrapped TextXAlignment={Enum.TextXAlignment.Left} TextYAlignment={Enum.TextYAlignment.Top} > <uipadding PaddingBottom={new UDim(0, theme.space[12])} PaddingRight={new UDim(0, theme.space[24])} /> </Text> </Accordion.Content> {index < items.size() - 1 ? ( <frame BackgroundColor3={theme.colors.border} BackgroundTransparency={0.5} BorderSizePixel={0} LayoutOrder={2} Size={UDim2.fromOffset(320, 1)} /> ) : undefined} </frame> </Accordion.Item> ))} </frame> </Accordion.Root> </frame> );}Import
import { Accordion } from "@lattice-ui/react-accordion";Anatomy
Compose Root around a list of Items. Each item carries a unique value, wraps a Header/Trigger pair, and a Content body that mounts only while the item is open.
Accordion anatomy
<Accordion.Root> <Accordion.Item value="..."> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content /> </Accordion.Item></Accordion.Root>| 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.
import { Accordion } from "@lattice-ui/react-accordion";
export function FaqAccordion() { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 200)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Accordion.Root defaultValue="rewards" onValueChange={(value) => print(`open section: ${value}`)} > <Accordion.Item value="rewards"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Daily rewards reset at midnight UTC." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item>
<Accordion.Item value="trading"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Trading unlocks at level 10." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> </Accordion.Root> </frame> );}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.
import { Accordion } from "@lattice-ui/react-accordion";
export function InventoryFilters() { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 260)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Accordion.Root type="multiple" defaultValue={["rarity", "slot"]} onValueChange={(value) => print(`open sections: ${(value as Array<string>).join(", ")}`)} > <Accordion.Item value="rarity"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Common, rare, epic, legendary." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item>
<Accordion.Item value="slot"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Weapon, armor, accessory." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> </Accordion.Root> </frame> );}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.
import { Accordion } from "@lattice-ui/react-accordion";
export function SettingsAccordion() { return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 200)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Accordion.Root type="single" collapsible defaultValue="audio"> <Accordion.Item value="audio"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Master volume, music, and SFX sliders." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item>
<Accordion.Item value="controls"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Rebind movement and action keys." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> </Accordion.Root> </frame> );}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.
import { useState } from "@rbxts/react";import { Accordion } from "@lattice-ui/react-accordion";
export function QuestLog() { const [value, setValue] = useState<string | Array<string>>("");
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 240)}> <uilistlayout Padding={new UDim(0, 6)} SortOrder={Enum.SortOrder.LayoutOrder} />
<textbutton BackgroundColor3={Color3.fromRGB(88, 142, 255)} Event={{ Activated: () => setValue("daily") }} Size={UDim2.fromOffset(260, 28)} Text="Jump to daily quests" TextColor3={Color3.fromRGB(240, 244, 252)} />
<Accordion.Root type="single" collapsible value={value} onValueChange={setValue}> <Accordion.Item value="daily"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Defeat 10 slimes. Collect 3 herbs." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item>
<Accordion.Item value="story"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="Speak to the harbor master." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> </Accordion.Root> </frame> );}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), 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.
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<string | Array<string>>("party");
return ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 200)}> <uilistlayout Padding={new UDim(0, 4)} SortOrder={Enum.SortOrder.LayoutOrder} />
<Accordion.Root type="single" collapsible value={value} onValueChange={setValue}> {SECTIONS.map((section) => { const open = value === section.value;
return ( <Accordion.Item value={section.value} key={section.value}> <Accordion.Header> <Accordion.Trigger asChild> <textbutton AutoButtonColor={false} BorderSizePixel={0} Size={UDim2.fromScale(1, 1)} Text="" > <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} /> <uicorner CornerRadius={new UDim(0, 6)} /> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(0.8, 1)} Text={section.label} TextColor3={Color3.fromRGB(236, 241, 249)} TextXAlignment={Enum.TextXAlignment.Left} /> <imagelabel AnchorPoint={new Vector2(1, 0.5)} BackgroundTransparency={1} Image="rbxassetid://1234567890" Position={UDim2.fromScale(1, 0.5)} Rotation={open ? 180 : 0} Size={UDim2.fromOffset(12, 12)} /> </textbutton> </Accordion.Trigger> </Accordion.Header> <Accordion.Content Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)}> <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text={section.body} TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> ); })} </Accordion.Root> </frame> );}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.
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 ( <frame BackgroundTransparency={1} Size={UDim2.fromOffset(260, 100)}> <Accordion.Root type="single" collapsible defaultValue="latest"> <Accordion.Item value="latest"> <Accordion.Header> <Accordion.Trigger /> </Accordion.Header> <Accordion.Content forceMount transition={DEEP_REVEAL} Position={UDim2.fromOffset(0, 34)} Size={UDim2.fromOffset(260, 46)} > <textlabel BackgroundTransparency={1} Size={UDim2.fromScale(1, 1)} Text="v1.4: new dungeon, balance changes." TextColor3={Color3.fromRGB(205, 211, 224)} /> </Accordion.Content> </Accordion.Item> </Accordion.Root> </frame> );}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, andonValueChangereceives 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, andonValueChangereceives 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 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.
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.
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.
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.
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<string> | Controlled open value(s). Use a string for single mode and an array for multiple mode. Pair with onValueChange. |
| defaultValue | string | Array<string> | Initial open value(s) for uncontrolled usage. Defaults to "" in single mode and [] in multiple mode. |
| onValueChange | (value: string | Array<string>) => 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.
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"]