npx facet-rbxts add cardCopies ui/card.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants and
@lattice-ui/react-runtime.
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,} from "../shared/ui/card";import { Button } from "../shared/ui/button";
<Card> <CardHeader> <CardTitle Text="Shop" /> <CardDescription Text="Everything here is a copied-in component." /> </CardHeader> <CardContent> <Label Text="Nothing for sale yet." /> </CardContent> <CardFooter> <Button size="sm" Text="Buy" /> <Button size="sm" variant="outline" Text="Cancel" /> </CardFooter></Card>The parts
| Part | Renders | Classes |
|---|---|---|
Card | Frame | flex-col w-full h-fit rounded-lg border border-border bg-card |
CardHeader | Frame | flex-col w-full h-fit gap-1 p-6 |
CardTitle | TextLabel | w-full h-fit whitespace-normal text-left text-xl font-semibold text-card-foreground |
CardDescription | TextLabel | w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal text-muted-foreground |
CardContent | Frame | flex-col w-full h-fit gap-2 px-6 pb-6 |
CardFooter | Frame | flex-row items-center w-full h-fit gap-2 px-6 pb-6 |
Flat named exports, not Card.Header. This is source you paste and edit, so each part reads, and
deletes, on its own.
import React from "@rbxts/react";import { Badge } from "../ui/badge";import { Button } from "../ui/button";import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle,} from "../ui/card";import { Label } from "../ui/label";import { MODE } from "../facet-mode";
export function ShopCard() { return ( <Card> <CardHeader> <CardTitle Text="Trader's Pack" /> <CardDescription Text="Everything you need for the first run — one purchase, no timers." /> </CardHeader> <CardContent> <frame className="flex-row items-center gap-2 w-full h-fit"> <Badge Text="Limited" /> <Badge variant="outline" Text="2 left" /> </frame> <Label Text="1,200 coins" /> </CardContent> <CardFooter> <Button size="sm" Text="Buy" /> <Button size="sm" variant="outline" Text="Cancel" /> </CardFooter> </Card> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * Flat named exports rather than a `Card.Header` namespace: this is source you * paste and edit, and each part should read — and be deletable — on its own. * * Every part is `w-full h-fit`. That is the whole layout strategy: width comes * from the parent, height from the content, all the way down. Break the chain at * any level — a part with no resolved height — and the card above it collapses. * * `font-normal` on the description is not redundant: Vela leaves `FontFace` * alone when no `font-*` token appears, and Roblox's untouched default is * LegacyArial — a different typeface at a visibly different size from the * SourceSansPro every other label here resolves to. * * shadcn has a seventh part, `CardAction`, and this file does not. It is placed * entirely by grid — `col-start-2 row-span-2 justify-self-end` inside a header * that is a two-column grid — and Vela lowers `grid` to a `UIGridLayout` with * uniform cells, which cannot express any of those three. A part that cannot * position itself is worse than no part; put the action in the header and give * it `self-end`. *//** * One object rather than six exported recipes, and it is not a style choice: * Vela inlines its whole runtime into every file with a computed `className`, * which leaves a component only a slice of Luau's 200-register limit for its own * module-scope locals. Six separate `export const`s put this file over it and * the module stopped loading entirely — "Out of local registers when trying to * allocate CardHeader". Each exported name costs a register; one object costs * one. See docs/decisions/luau-register-limit.md. */export const cardVariants = { // The padding is split the way shadcn splits it, and the split is the point: // the vertical padding is the card's (`py-6`), the horizontal is each part's // (`px-6`). That is what lets a part run edge to edge — a full-bleed image in // `CardContent` — by dropping one class instead of unpicking the card's. root: fv("flex-col w-full h-fit gap-6 rounded-xl border border-border bg-card py-6 shadow"), header: fv("flex-col w-full h-fit gap-2 px-6"), // `text-base` is the browser's inherited body size restated; shadcn's title // sets weight and leading only. It is not `text-xl`. title: fv("w-full h-fit whitespace-normal leading-none text-left text-base font-semibold text-card-foreground"), description: fv("w-full h-fit whitespace-normal leading-tight text-left text-sm font-normal text-muted-foreground"), content: fv("flex-col w-full h-fit px-6"), footer: fv("flex-row items-center w-full h-fit px-6"),};
// Wrapping and alignment are classes: Roblox centres text and leaves it on one// line by default, so `text-left` and `whitespace-normal` correct both. Text and// frame parts share these, so one table serves both.const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
const FRAME_OWN_PROPS = ["className", "children"] as const;const TEXT_OWN_PROPS = ["className", "Text"] as const;
export type CardProps = { className?: ClassName; children?: React.ReactNode } & PassthroughProps<Frame>;export type CardTextProps = { className?: ClassName; Text?: string } & PassthroughProps<TextLabel>;
export function Card(props: CardProps) { return ( <frame className={cn(cardVariants.root({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, FRAME_OWN_PROPS)} > {props.children} </frame> );}
export function CardHeader(props: CardProps) { return ( <frame className={cn(cardVariants.header({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, FRAME_OWN_PROPS)} > {props.children} </frame> );}
export function CardTitle(props: CardTextProps) { return ( <textlabel className={cn(cardVariants.title({ className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}
export function CardDescription(props: CardTextProps) { return ( <textlabel className={cn(cardVariants.description({ className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}
export function CardContent(props: CardProps) { return ( <frame className={cn(cardVariants.content({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, FRAME_OWN_PROPS)} > {props.children} </frame> );}
export function CardFooter(props: CardProps) { return ( <frame className={cn(cardVariants.footer({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, FRAME_OWN_PROPS)} > {props.children} </frame> );}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.
The frame parts (Card, CardHeader, CardContent, CardFooter) forward onto a Frame and take
children. The text parts (CardTitle, CardDescription) forward onto a TextLabel and take
Text.
| Prop | Type | Description |
|---|---|---|
| Text | string | Text parts only. Drawn as the instance's own Text — these are leaves. |
| children | React.ReactNode | Frame parts only. |
| className | ClassName | Threaded into the 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. |
w-full h-fit, all the way down
Every part carries it, and that is the layout strategy: width from the parent, height from the content. Break the chain at any level and the card above it collapses.
A container hugging its content can only measure children that already know their own size. It is
not enough for Card to say h-fit, because every level under it has to answer too.
Six parts, one recipe object
export const cardVariants = { root: fv("flex-col w-full h-fit rounded-lg border border-border bg-card"), header: fv("flex-col w-full h-fit gap-1 p-6"), title: fv("w-full h-fit whitespace-normal text-left text-xl font-semibold text-card-foreground"), description: fv("w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal text-muted-foreground"), content: fv("flex-col w-full h-fit gap-2 px-6 pb-6"), footer: fv("flex-row items-center w-full h-fit gap-2 px-6 pb-6"),};Vela once inlined its whole runtime into every file with a computed className, leaving a component only a slice of Luau’s 200-register limit. Six separate export consts put this file over it and the module stopped loading. Each exported name costs a register. One object costs one.
Emitted files now sit around 24 module-scope locals, which is why ^0.9.0 is the floor the CLI installs. The object shape stayed because it is cheap.
Wrapping and alignment are classes
Roblox centres text and leaves it on one line, so text-left and whitespace-normal are on every
text part. Without them a description is one centred line running off the edge.
leading-tight on the description is the one place card leans on a Vela family that reached the
computed-className path in 0.8.0. Below that floor it compiles and silently does nothing.
font-normal on the description looks redundant beside font-semibold on the title, and is not.
Vela leaves FontFace alone when no font-* appears, and Roblox’s default is LegacyArial. The
description rendered in Arial beside a SourceSansPro title until someone opened it in Studio.
One layout per instance
flex-col, items-*, justify-* and gap-* all lower onto a single UIListLayout child, and one
instance holds one layout. A part that sets any of them owns its children’s arrangement, so a
consumer wanting a different one replaces those classes rather than adding to them.
Wanting a second layout inside CardContent is a sign to restructure the parts, which are yours to
restructure.