npx facet-rbxts add alertCopies ui/alert.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants and
@lattice-ui/react-runtime.
import { Alert, AlertTitle, AlertDescription } from "../shared/ui/alert";
<Alert> <AlertTitle Text="Saved" /> <AlertDescription Text="Your loadout is stored on the server." /></Alert>import React from "@rbxts/react";import { Alert, AlertDescription, AlertTitle } from "../ui/alert";import { MODE } from "../facet-mode";
export function Alerts() { return ( <frame className="flex-col gap-3 w-full h-fit"> <Alert> <AlertTitle Text="Loadout saved" /> <AlertDescription Text="Your changes are stored on the server and will follow you between places." /> </Alert> <Alert variant="destructive"> <AlertTitle variant="destructive" Text="Kicked" /> <AlertDescription variant="destructive" Text="You were removed from the server for being idle." /> </Alert> </frame> );}import { fv, type VariantProps } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React } from "@lattice-ui/react-runtime";import { type ClassName, cn } from "~/lib/utils";
/** * One object rather than three exported recipes: every exported name costs a * Luau register, and Vela inlines its runtime into any file with a computed * `className`, leaving a component only a slice of the 200 available. `card` * stopped loading entirely over exactly this — see * docs/decisions/luau-register-limit.md. * * `w-full h-fit` all the way down, like `card`: width from the parent, height * from the content, and any part that fails to resolve a height collapses the * alert above it. */export const alertVariants = { // `px-4 py-3` and `gap-y-0.5` are shadcn's, to the pixel. What is not shadcn's // is `flex-col`: the original is a two-column grid whose empty first column // reserves room for an icon, and Vela's `grid` lowers to a `UIGridLayout` with // uniform cells — it cannot express `grid-cols-[0_1fr]`. A column is the // honest shape for a component that has no icon slot here. root: fv("flex-col w-full h-fit gap-0.5 rounded-lg border border-border px-4 py-3", { variants: { variant: { default: "bg-card", // shadcn's destructive alert keeps the default border and recolours the // text only. It looked like an oversight and is not one: the variant is // `bg-card text-destructive`. destructive: "bg-card", }, }, defaultVariants: { variant: "default" }, }), // `text-sm` on both parts is the root's `text-sm` restated: shadcn sets it // once on the root and lets it cascade, and nothing cascades here. title: fv("w-full h-fit whitespace-normal text-left text-sm font-medium", { variants: { variant: { default: "text-card-foreground", destructive: "text-destructive", }, }, defaultVariants: { variant: "default" }, }), description: fv("w-full h-fit whitespace-normal leading-tight text-left text-sm font-normal", { variants: { variant: { default: "text-muted-foreground", // shadcn dims this one against the title: `text-destructive/90`. destructive: "text-destructive/90", }, }, defaultVariants: { variant: "default" }, }),};
/** * Each part takes its own `variant`, which is the thing to notice if you are * coming from shadcn. There it is set once on the root and a descendant * selector colours the title; here nothing inherits, so `variant` has to reach * every part that draws something: * * ```tsx * <Alert variant="destructive"> * <AlertTitle variant="destructive" Text="Kicked" /> * <AlertDescription variant="destructive" Text="You were removed from the server." /> * </Alert> * ``` * * Verbose on purpose. The alternative is a context, and a context is a thing * the consumer then owns and has to keep wired up through their own edits — for * a component whose whole body is three instances. */export type AlertProps = VariantProps<typeof alertVariants.root> & { className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type AlertTextProps = VariantProps<typeof alertVariants.title> & { className?: ClassName; Text?: string;} & PassthroughProps<TextLabel>;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
const FRAME_OWN_PROPS = ["variant", "className", "children"] as const;const TEXT_OWN_PROPS = ["variant", "className", "Text"] as const;
export function Alert(props: AlertProps) { return ( <frame className={cn(alertVariants.root({ variant: props.variant, className: props.className }))} // The border is the alert's own surface, so this one keeps its background // and only drops Roblox's 1px border. BorderSizePixel={0} {...getPassthroughProps<Frame>(props, FRAME_OWN_PROPS)} > {props.children} </frame> );}
export function AlertTitle(props: AlertTextProps) { return ( <textlabel className={cn(alertVariants.title({ variant: props.variant, className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}
export function AlertDescription(props: AlertTextProps) { return ( <textlabel className={cn(alertVariants.description({ variant: props.variant, className: props.className }))} Text={props.Text ?? ""} {...NEUTRAL_PROPS} {...getPassthroughProps<TextLabel>(props, TEXT_OWN_PROPS)} /> );}The parts
| Part | Renders | Classes |
|---|---|---|
Alert | Frame | flex-col w-full h-fit gap-1 rounded-lg border p-4 |
AlertTitle | TextLabel | w-full h-fit whitespace-normal text-left text-sm font-medium |
AlertDescription | TextLabel | w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal |
Flat named exports, like Card — this is source you paste and edit, so each part reads and deletes on its own.
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.
Alert forwards onto a Frame and takes children. The two text parts forward onto a TextLabel
and take Text.
Props
| Prop | Type | Description |
|---|---|---|
| variant | "default" | "destructive" | Takes it on every part, not just the root. Defaults to default. See below — this is the thing to notice if you are coming from shadcn. |
| Text | string | Text parts only. Drawn as the instance's own Text — these are leaves. |
| children | React.ReactNode | Root 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. |
variant goes on every part
This is the one surprise in the component, and it is not an oversight:
<Alert variant="destructive"> <AlertTitle variant="destructive" Text="Kicked" /> <AlertDescription variant="destructive" Text="You were removed from the server." /></Alert>In shadcn the variant is set once on the root and a descendant selector colours the title. Roblox
has no descendant selectors and nothing inherits — a TextLabel’s colour lives on that
TextLabel — so variant has to reach every part that draws something.
Verbose on purpose. The alternative is a React context, and a context is a thing you then own and have to keep wired up through your own edits, for a component whose entire body is three instances. If you find the repetition annoying in your project, the file is yours: add the context, or collapse the three parts into one component that takes two strings.
<Alert variant="destructive"> with a plain <AlertTitle /> compiles, renders, and gives you a
destructive border around a default-coloured title. Nothing warns — there is no relationship between
the parts for anything to check.
variant | Root | Title | Description |
|---|---|---|---|
default | bg-card border-border | text-card-foreground | text-muted-foreground |
destructive | bg-card border-destructive | text-destructive | text-destructive |
The destructive surface is the same bg-card as the default — only the border and the text
change. A red fill behind body text is a contrast problem the theme cannot solve, and the border
carries the signal on its own.
Three parts, one recipe object
export const alertVariants = { root: fv("flex-col w-full h-fit gap-1 rounded-lg border p-4", { variants: { … } }), title: fv("w-full h-fit whitespace-normal text-left text-sm font-medium", { variants: { … } }), description: fv("w-full h-fit whitespace-normal leading-tight text-left text-xs font-normal", { variants: { … } }),};One object rather than three export consts, for the reason
Card is written the same way: every exported
name costs a Luau register, Vela inlines its runtime into any file with a computed className, and
card went over the 200-register limit and stopped loading entirely. Three parts is nowhere near
that ceiling — the shape is the convention, kept because it is free.
See the register limit.
w-full h-fit, all the way down
Every part carries it, exactly as card does: width from the parent, height from the content.
A part that fails to resolve a height collapses the alert above it.
whitespace-normal and text-left are on both text parts because Roblox centres text and keeps it
on one line by default — an alert description is the case where that matters most, since it is the
part most likely to wrap.
font-normal on the description is not redundant next to font-medium on the title. Vela leaves
FontFace alone when no font-* token appears, and Roblox’s untouched default is LegacyArial —
which is the bug that shipped in card
before anyone opened it in Studio.
The root keeps its background
<frame className={cn(alertVariants.root({ variant, className }))} BorderSizePixel={0} …/>No BackgroundTransparency: 1 on the root, unlike most components — the surface is the alert.
Only Roblox’s 1px border is cleared, because border in the recipe is what draws the real one. The
two text parts do clear their backgrounds, since a TextLabel with an opaque default would paint a
box behind every line.
That is the same rule Separator follows, reached from the other direction.