Latticecomponents

Accordion

A disclosure primitive owning open-value state, expansion mode and per-item presence motion.

@lattice-ui/react-accordionStable directionimport Accordiondepends on runtime, focus, layer, motion

Accordion 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

Edit

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 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, so pass the trigger a Text and a size. 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 — the fit for filter panels and stat sheets. Items always toggle closed in this mode, so 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 its trigger does nothing. Add collapsible when every panel should be closable.

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 opens sections — a “track quest” button, a deep link, a reset. In single mode the value is a string and "" means nothing is open.

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

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 closed, visibility still bound to presence, so children keep their state and you can measure or drive motion yourself.

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 the open item value(s), not booleans, and type selects the policy:

  • type="single", the default, keeps at most one item open. onValueChange receives 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.

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"]