Facetcomponents

Card

Six flat parts, one shared recipe object, and the Luau register limit that forced it.

Terminal window
npx facet-rbxts add card

Copies 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

PartRendersClasses
CardFrameflex-col w-full h-fit rounded-lg border border-border bg-card
CardHeaderFrameflex-col w-full h-fit gap-1 p-6
CardTitleTextLabelw-full h-fit whitespace-normal text-left text-xl font-semibold text-card-foreground
CardDescriptionTextLabelw-full h-fit whitespace-normal leading-tight text-left text-xs font-normal text-muted-foreground
CardContentFrameflex-col w-full h-fit gap-2 px-6 pb-6
CardFooterFrameflex-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.

All six parts, composed with Badge, Label and Button.

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.

PropTypeDescription
TextstringText parts only. Drawn as the instance's own Text — these are leaves.
childrenReact.ReactNodeFrame parts only.
classNameClassNameThreaded 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"),
};

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.