npx facet-rbxts add accordionCopies 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>import React from "@rbxts/react";import { Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "../ui/accordion";import { MODE } from "../facet-mode";
/** * The chevron is a `▾` rotated 180° when its item is open — no icon font * exists on Roblox, so it is a `textlabel` like every other glyph in the * registry. */export function Faq() { return ( <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 — nothing is kept on the client." /> </AccordionItem> <AccordionItem value="trade"> <AccordionTrigger Text="Can I trade with other players?" /> <AccordionContent Text="Only inside a safe zone, and both of you have to confirm." /> </AccordionItem> </Accordion> );}import { fv } from "@facet-ui/react-variants";import { Accordion as AccordionPrimitive, type AccordionType } from "@lattice-ui/react-accordion";import { getPassthroughProps, type PassthroughProps, React, toSlotProps, useControllableState,} from "@lattice-ui/react-runtime";import { TextSlot } from "~/lib/text";import { type ClassName, cn } from "~/lib/utils";
/** * The open values are mirrored here with `useControllableState` — the same * hook the primitive uses — because Lattice keeps its context private and the * trigger's chevron flips with its item. The primitive is then driven * controlled, so there is exactly one copy of the state; the item hands its * own `open` down a second context so the trigger does not need to know the * item's value. * * One recipe object rather than seven exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const accordionVariants = { // The rule between items is `divide-y` on the root, not `border-b` on the // item. A `border-*` class lowers to a `UIStroke`, which outlines the whole // instance — Roblox has no per-side stroke, so Vela drops `border-b` and the // surviving `border-border` would box every item. `divide-y` interleaves real // one-pixel frames between children, and leaves no rule under the last item. root: fv("flex-col w-full h-fit divide-y divide-border"), item: fv("flex-col w-full h-fit"), // `items-start` is shadcn's, and it is the one that matters when the heading // wraps: the chevron stays level with the first line rather than drifting to // the middle of the block. trigger: fv("flex-row items-start justify-between w-full h-fit gap-4 rounded-md py-4"), triggerLabel: fv("text-left text-sm font-medium text-foreground"), chevron: fv("size-fit text-xs font-normal text-muted-foreground text-center"), content: fv("flex-col gap-2 w-full h-fit overflow-hidden pb-4"), contentText: fv("w-full h-fit whitespace-normal text-left text-sm font-normal text-muted-foreground"),};
const AccordionContext = React.createContext<{ isOpen: (value: string) => boolean } | undefined>(undefined);const AccordionItemContext = React.createContext<{ open: boolean } | undefined>(undefined);
export type AccordionProps = { /** `single` closes the previous item when the next opens; `multiple` lets them accumulate. */ type?: AccordionType; value?: string | string[]; defaultValue?: string | string[]; onValueChange?: (value: string | string[]) => void; /** In `single` mode, whether the open item can be clicked closed again. */ collapsible?: boolean; className?: ClassName; children?: React.ReactNode;};
export type AccordionItemProps = { value: string; disabled?: boolean; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type AccordionTriggerProps = { Text?: string; className?: ClassName; children?: React.ReactNode;};
export type AccordionContentProps = { Text?: string; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
const ITEM_OWN_PROPS = ["value", "disabled", "className", "children"] as const;const CONTENT_OWN_PROPS = ["Text", "className", "children"] as const;
export function Accordion(props: AccordionProps) { const [value, setValue] = useControllableState<string | string[] | undefined>({ value: props.value, defaultValue: props.defaultValue, onChange: props.onValueChange as (value: string | string[] | undefined) => void, });
const isOpen = React.useCallback( (itemValue: string) => { if (typeIs(value, "table")) { return (value as string[]).includes(itemValue); } return value === itemValue; }, [value], );
const contextValue = React.useMemo(() => ({ isOpen }), [isOpen]);
return ( <AccordionPrimitive.Root collapsible={props.collapsible} onValueChange={setValue} type={props.type} value={value ?? (props.type === "multiple" ? [] : "")} > <AccordionContext.Provider value={contextValue}> <frame className={cn(accordionVariants.root({ className: props.className }))} BackgroundTransparency={1}> {props.children} </frame> </AccordionContext.Provider> </AccordionPrimitive.Root> );}
export function AccordionItem(props: AccordionItemProps) { const accordion = React.useContext(AccordionContext); if (accordion === undefined) { error("[AccordionItem] must be rendered inside an Accordion."); }
const open = accordion.isOpen(props.value); const itemContextValue = React.useMemo(() => ({ open }), [open]);
return ( <AccordionItemContext.Provider value={itemContextValue}> <AccordionPrimitive.Item className={cn(accordionVariants.item({ className: props.className }))} disabled={props.disabled} value={props.value} {...toSlotProps(getPassthroughProps<Frame>(props, ITEM_OWN_PROPS))} > {props.children} </AccordionPrimitive.Item> </AccordionItemContext.Provider> );}
export function AccordionTrigger(props: AccordionTriggerProps) { const item = React.useContext(AccordionItemContext); if (item === undefined) { error("[AccordionTrigger] must be rendered inside an AccordionItem."); }
// No layout class on the header: `flex-*` would lower to a `uilistlayout` // sibling next to its single child, and the primitive types `children` as // one element. return ( <AccordionPrimitive.Header className="w-full h-fit"> <AccordionPrimitive.Trigger className={cn(accordionVariants.trigger({ className: props.className }))}> {/* biome-ignore lint/complexity/noUselessFragments: the primitive types `children` as a single element (what `asChild` merges onto), so the label and the chevron have to arrive as one. */} <> <TextSlot Text={props.Text} className={cn(accordionVariants.triggerLabel())}> {props.children} </TextSlot> {/* An icon font does not exist on Roblox, so the chevron is a text glyph, flipped by rotation — replace it to use your own artwork. */} <textlabel className={cn(accordionVariants.chevron())} Rotation={item.open ? 180 : 0} Text="▾" BackgroundTransparency={1} BorderSizePixel={0} /> </> </AccordionPrimitive.Trigger> </AccordionPrimitive.Header> );}
export function AccordionContent(props: AccordionContentProps) { return ( <AccordionPrimitive.Content className={cn(accordionVariants.content({ className: props.className }))} {...toSlotProps(getPassthroughProps<Frame>(props, CONTENT_OWN_PROPS))} > <TextSlot Text={props.Text} className={cn(accordionVariants.contentText())}> {props.children} </TextSlot> </AccordionPrimitive.Content> );}The parts
| Part | Renders | Classes |
|---|---|---|
Accordion | Frame | flex-col w-full h-fit divide-y divide-border |
AccordionItem | Frame | flex-col w-full h-fit |
AccordionTrigger | TextButton | flex-row items-center justify-between w-full h-fit py-4 |
AccordionContent | Frame | flex-col gap-2 w-full h-fit pb-4 |
The trigger’s label and the chevron are two more instances inside it, and the content’s text is one more inside that. Seven recipe entries, four exported parts — the rest take no props of their own, and 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, and a UIStroke outlines the whole instance. There are no
per-side strokes, so Vela treats border-b as
unsupported and drops it — silently here, because
a recipe’s classes are resolved by the Vela runtime rather than at compile time, so the compiler’s
unsupported-border diagnostic never fires. What would survive is border-border, which sets the
stroke’s colour and clears its transparency, and a UIStroke at its default thickness of 1 draws on
all four sides. Every item would come out boxed, not underlined.
divide-y has no such problem. Vela lowers it to real one-pixel frames interleaved between the
root’s children, painted by divide-border. Two items get one rule between them and nothing under
the last — the same thing shadcn’s border-b last:border-b-0 renders.
The file is yours, so:
- A heavier rule is
divide-y-2on the root — the number is the thickness in pixels, and the family takes0,1,2,4or8. - No rule at all — drop both
divide-*classes. - A rule you place yourself — use
Separatorbetween items instead. That is a real one-pixel frame in the flow, and it is what the registry has for dividers elsewhere.
What does not work is a smarter border class. border-t, border-x and every prefixed form are on
the same unsupported list.
Props
Accordion
| Prop | Type | Description |
|---|---|---|
| type | "single" | "multiple" | single closes the previous item when the next opens; multiple lets them accumulate. |
| value | string | string[] | Controlled open item(s). |
| defaultValue | string | string[] | Uncontrolled starting state. |
| onValueChange | (value: string | string[]) => void | Fires when an item opens or closes. |
| collapsible | boolean | In single mode, whether the open item can be clicked shut again. |
| className | ClassName | Threaded 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, because Lattice’s own context is private and the chevron rotates with it.
The second context is AccordionItemContext, and it carries one boolean:
const AccordionItemContext = React.createContext<{ open: boolean } | undefined>(undefined);Without it, AccordionTrigger would need to know its item’s value to ask the first context —
which means either passing value twice in the markup, or reaching into the primitive. The item
already knows; it hands its own answer down.
The header carries no layout class
<AccordionPrimitive.Header className="w-full h-fit"> <AccordionPrimitive.Trigger …>No flex-* on the header, deliberately. A flex-* class lowers to a UIListLayout sibling next
to the header’s single child — and the primitive types children as one element, so there is no
room for the layout instance beside it. Both axes are still declared, which is
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. That is a
settled position,
and swapping it for an imagelabel with your own artwork changes nothing else in the file.