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. Lattice uses namespace objects and that is right for a
library; this is source you paste and edit, so each part reads — and can be deleted — 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 entire layout strategy: width comes from the parent, height from the content. Break the chain at any level — one part with no resolved height — and the card above it collapses.
That is the AutomaticSize chain in its most visible form. A container set to hug its content can
only measure children that already know their own size, so it is not enough for Card to say
h-fit; 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 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 consts 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.
Vela 0.9.0 scoped that runtime into a single initializer and the emitted files dropped from ~106
module-scope locals to ~24, which is why ^0.9.0 is the floor the CLI installs. The object shape
stayed: it is cheap, and the headroom is worth keeping.
Wrapping and alignment are classes
Roblox centres text and leaves it on one line by default. text-left and whitespace-normal
correct both, on every text part. Without them a card 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 only landed
in 0.8.0 on the computed-className path — below that floor it compiles and silently does nothing.
font-normal on the description looks redundant next to font-semibold on the title, and is not.
Vela leaves FontFace alone when no font-* token appears, and Roblox’s untouched default is
LegacyArial. This exact line is why: the description rendered in Arial next to a SourceSansPro
title, inside the same header, for as long as nobody had opened it in Studio.
One layout per instance
flex-col, items-*, justify-* and gap-* all lower onto a single UIListLayout child, and one
instance can hold one layout. So a part that sets any of them owns the arrangement of its children;
a consumer who wants a different one replaces the part’s layout classes rather than adding to
them.
If you find yourself wanting a second layout inside CardContent, do not add a wrapper frame for
it. Restructure the parts — that is what they are for, and they are yours to restructure.