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 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.
The file is yours, so:
- A heavier rule.
divide-y-2on the root. The family takes0,1,2,4or8. - No rule at all — drop both
divide-*classes. - A rule you place yourself —
Separatorbetween items, which is what the registry uses 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, 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.