Facetcomponents

Accordion

Seven recipe entries, two contexts, and a divider that moved to the root to survive Roblox.

Terminal window
npx facet-rbxts add accordion

Copies ui/accordion.tsx, plus lib/utils.ts and lib/text.tsx. Needs @facet-ui/react-variants, @lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-accordion@^0.8.0.

import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../shared/ui/accordion";
<Accordion type="single" collapsible defaultValue="saves">
<AccordionItem value="saves">
<AccordionTrigger Text="Where is my progress stored?" />
<AccordionContent Text="On the server, keyed to your account." />
</AccordionItem>
</Accordion>
One item open, one closed, with a single rule between them — drawn by divide-y on the root, for the reason below.

The parts

PartRendersClasses
AccordionFrameflex-col w-full h-fit divide-y divide-border
AccordionItemFrameflex-col w-full h-fit
AccordionTriggerTextButtonflex-row items-center justify-between w-full h-fit py-4
AccordionContentFrameflex-col gap-2 w-full h-fit pb-4

The trigger’s label and chevron are two more instances inside it, and the content’s text one more inside that. Seven recipe entries, four exported parts — every export would cost a Luau register. See the register limit.

Renders a Frame. Unknown props forward onto it and are type-checked against it, so a prop Frame does not accept is a compile error.

That is AccordionItem and AccordionContent. Accordion and AccordionTrigger take no passthrough bag.

The rule between items lives on the root

shadcn draws the divider with border-b on each item. That class cannot work on Roblox, so this recipe moves the rule up to the root:

root: fv("flex-col w-full h-fit divide-y divide-border"),
item: fv("flex-col w-full h-fit"),

border-* lowers to a UIStroke, which outlines the whole instance, so Vela drops border-b. It is silent here, since a recipe’s classes resolve at runtime and the compiler’s diagnostic never fires. What survives is border-border, colouring a stroke that draws on all four sides, so every item comes out boxed rather than underlined.

divide-y has no such problem: Vela lowers it to real one-pixel frames interleaved between the root’s children. Two items get one rule between them and nothing under the last — what shadcn’s border-b last:border-b-0 renders.

Props

Accordion

PropTypeDescription
type"single" | "multiple"single closes the previous item when the next opens; multiple lets them accumulate.
valuestring | string[]Controlled open item(s).
defaultValuestring | string[]Uncontrolled starting state.
onValueChange(value: string | string[]) => voidFires when an item opens or closes.
collapsiblebooleanIn single mode, whether the open item can be clicked shut again.
classNameClassNameThreaded into the root recipe's className slot inside the component. A class written at a Vela-compiled call site never reaches it — see Overriding from the call site.

The other parts

AccordionItem takes a required value, plus disabled and className. AccordionTrigger and AccordionContent each take Text, children and className.

Two contexts, and why the second one exists

The mirrored state is the set of open values, held on Accordion with useControllableState. A Facet context hands isOpen(value) down, since Lattice’s own is private and the chevron rotates with it.

The second context, AccordionItemContext, carries one boolean:

const AccordionItemContext = React.createContext<{ open: boolean } | undefined>(undefined);

Without it, AccordionTrigger would need its item’s value to ask the first context — passing value twice, or reaching into the primitive. The item already knows, so it hands its answer down.

The header carries no layout class

<AccordionPrimitive.Header className="w-full h-fit">
<AccordionPrimitive.Trigger …>

No flex-* on the header, deliberately. It would lower to a UIListLayout sibling of the header’s single child. The primitive types children as one element, so there is no room for it. Both axes are still declared, per rule 1.

The same constraint is why the trigger’s label and chevron arrive wrapped in a fragment: the primitive wants one child, and there are two things to draw.

The chevron is a rotated glyph

<textlabel Rotation={item.open ? 180 : 0} Text="▾" … />

Roblox has no icon font, so ▾ is a character and “up” is the same character turned over — a settled position. Swapping it for an imagelabel changes nothing else in the file.