@lattice-ui/react-accordionStable directionimport Accordiondepends on runtime, focus, layer, motionAccordion is the primitive for a stack of disclosure sections: FAQ lists, settings groups, inventory
categories. It owns which items are open, keyed by a string value, and handles each section’s
reveal and exit motion.
Reach for it when labelled sections share an expansion policy — one open at a time (single) or
any number (multiple).
Preview
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 carries a unique value, a Header/Trigger pair,
and a Content body that mounts only while 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, so pass the
trigger a Text and a size. 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 — the fit
for filter panels and stat sheets. Items always toggle closed in this mode, so 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 its trigger does nothing.
Add collapsible when every panel should be closable.
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 opens sections — a “track quest” button, a
deep link, a reset. In single mode the value is a string and "" means nothing is open.
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 onto your own button and add a chevron that flips
when the section opens. Item open state is not exposed, so control the root and derive open from
your own value.
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 closed, visibility still bound to presence, so
children keep their state and you can measure or drive motion yourself.
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 the open item value(s), not booleans, and type selects the policy:
type="single", the default, keeps at most one item open.onValueChangereceives a string,""when everything is collapsed.type="multiple"allows any number. The value is a de-duplicated array.
The root normalizes whatever shape it is given — an array collapses to its first entry in single
mode, a lone string is wrapped in multiple mode. The same logic is exported as
normalizeAccordionValue and nextAccordionValues.
Accordion.Item considers itself open when its value is in the root’s open set, and each
Accordion.Trigger toggles through the shared toggle. Controlled and uncontrolled accordions behave
identically.
Collapsible
collapsible only affects type="single". With it, activating the open trigger collapses it.
Without it, which is the default, one item always stays open. In type="multiple" items can always
be toggled closed.
Structure and layout
Accordion.Root renders no instance, so item frames become direct children of the surrounding
container and a uilistlayout there stacks them. Item and Header each render a transparent
frame with no size of its own, and nothing auto-flows inside an item.
Focus and selection
Accordion.Trigger renders a textbutton toggling on Activated and on Return/Space. A
disabled item’s trigger ignores both and drops Active. It sets Selectable={false} on itself —
render your own selectable element through asChild for gamepad reach.
With asChild the toggle merges onto your single child element through Slot: the slot’s Active,
Selectable, handlers and ref win over the child’s, and handlers compose. Use a textbutton or
imagebutton so Activated fires.
Trigger color motion
The trigger’s BackgroundColor3 is yours — the primitive writes no colour 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. It runs no motion of its own.
Pass a PresenceMotionConfig as transition. createSurfaceRevealRecipe() gives a rise-and-fade.
It animates BackgroundTransparency, so give the body a background or animate its children. Under
asChild, your element replaces the frame and its Visible is bound to presence.
Open state stays internal
The item context is internal — there is no hook for reading whether an item is open. Control the root and derive the flag from your own state.
Match the shape of value/defaultValue to type: a string for "single", an array for "multiple". The root normalizes mismatches, but a consistent shape keeps onValueChange predictable.
Accordion.Header renders a transparent frame purely to position the trigger, with no state and no behaviour. Skip it if your layout does not need it.
The trigger writes no colour in either mode, so a background you set stays put.
The trigger renders no text of its own. 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"]