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, so variant has to reach every part that
draws something.
Verbose on purpose. The alternative is a React context you then own and keep wired through your own edits, for a component whose whole body is three instances. If the repetition annoys you, the file is yours — add the context, or collapse the parts into one component taking two strings.
<Alert variant="destructive"> with a plain <AlertTitle /> compiles and renders, giving 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 text change. A
red fill behind body text is a contrast problem the theme cannot solve, and the border carries the
signal alone.
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, and card once went over the 200-register limit and stopped loading.
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. That matters most for a description, the part most likely to wrap.
font-normal on the description is not redundant beside font-medium on the title. Vela leaves
FontFace alone when no font-* appears, and Roblox’s default is LegacyArial. That is
the bug that shipped in card.
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, since border in the recipe draws the real one. The text parts
do clear their backgrounds, or a TextLabel’s opaque default would paint a box behind every line.
That is the same rule Separator follows, reached from the other direction.