Latticecomponents

Accordion

A collapsible disclosure primitive that owns open-value state, single/multiple expansion modes, and per-item presence motion while you own the visuals.

@lattice-ui/react-accordionStable directionimport Accordiondepends 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.

Edit

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 anatomy
<Accordion.Root>
<Accordion.Item value="...">
<Accordion.Header>
<Accordion.Trigger />
</Accordion.Header>
<Accordion.Content />
</Accordion.Item>
</Accordion.Root>
PartRequiredResponsibility
Accordion.RootyesOwns the open-value state and the expansion policy, sharing both through context. Renders no instance of its own.
Accordion.ItemyesDeclares one section by value; derives its own open state from the root.
Accordion.HeadernoA transparent layout frame for the trigger; purely structural.
Accordion.TriggeryesA button that toggles its item open or closed.
Accordion.ContentyesThe 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.

FaqAccordion.tsx
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.

InventoryFilters.tsx
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.

SettingsAccordion.tsx
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.

QuestLog.tsx
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.

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<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.

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 (
<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, 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 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 transitioncreateSurfaceRevealRecipe() 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.

API reference

Accordion.Root

PropTypeDescription
type"single" | "multiple"Expansion policy. "single" keeps at most one item open; "multiple" allows many. Defaults to "single".
valuestring | Array<string>Controlled open value(s). Use a string for single mode and an array for multiple mode. Pair with onValueChange.
defaultValuestring | Array<string>Initial open value(s) for uncontrolled usage. Defaults to "" in single mode and [] in multiple mode.
onValueChange(value: string | Array<string>) => voidCalled whenever the open value(s) change. Receives a string in single mode and an array in multiple mode.
collapsiblebooleanIn 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.
childrenReact.ReactNodeThe accordion items.

Accordion.Item

PropTypeDescription
valuestringUnique key identifying this item. Determines whether the item is open based on the root's open value(s).
disabledbooleanPrevents the item's trigger from toggling. Defaults to false.
asChildbooleanMerge item layout onto the single child element instead of the frame the part renders.
childrenReact.ReactNodeThe header/trigger and content for this item.

Accordion.Header

PropTypeDescription
asChildbooleanMerge the header onto the single child element instead of rendering the default transparent frame.
childrenReact.ReactElementThe header contents, typically an Accordion.Trigger.

Accordion.Trigger

PropTypeDescription
asChildbooleanMerge trigger behavior onto the single child element instead of the textbutton the part renders. The child must be an activatable button.
childrenReact.ReactElementThe 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.

PropTypeDescription
forceMountbooleanKeeps the content mounted while closed and through its exit motion, instead of unmounting when the item closes.
transitionPresenceMotionConfigReveal/exit motion. None by default; pass createSurfaceRevealRecipe() for a rise-and-fade.
asChildbooleanMerge content behavior onto the single child element instead of rendering the default frame. The child's Visible property is bound to presence.
childrenReact.ReactNodeThe 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"]