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

```tsx title="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.

```tsx title="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.

```tsx title="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.

```tsx title="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](#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<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.

```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 (
    <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`](#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<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.

```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)
