npx facet-rbxts add badgeCopies ui/badge.tsx, plus lib/utils.ts and lib/text.tsx. Needs @facet-ui/react-variants and
@lattice-ui/react-runtime.
import { Badge } from "../shared/ui/badge";
<Badge Text="New" /><Badge variant="secondary" Text="Beta" /><Badge variant="destructive" Text="Banned" /><Badge variant="outline" Text="Draft" />import React from "@rbxts/react";import { Badge } from "../ui/badge";import { MODE } from "../facet-mode";
export function Badges() { return ( <frame className="flex-row items-center gap-2 w-full h-fit"> <Badge Text="Default" /> <Badge variant="secondary" Text="Secondary" /> <Badge variant="destructive" Text="Destructive" /> <Badge variant="outline" Text="Outline" /> </frame> );}import { fv, type VariantProps } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React } from "@lattice-ui/react-runtime";import { TextSlot } from "~/lib/text";import { cn } from "~/lib/utils";
// `border border-transparent` on the base is shadcn's, and it is load-bearing// here for a different reason than there. shadcn needs it so an outlined badge// is not a pixel larger than a filled one; a Roblox `UIStroke` is drawn on the// border and changes no size at all. What it does do is split the stroke in two:// this line sets its *thickness*, and `outline`'s `border-border` sets only its// *colour*. Drop the base token and the outline variant colours a stroke that// was never created.export const badgeVariants = fv( "flex-row items-center justify-center gap-1 size-fit overflow-hidden rounded-full border border-transparent px-2 py-0.5", { variants: { variant: { default: "bg-primary", secondary: "bg-secondary", destructive: "bg-destructive", outline: "border-border", }, }, defaultVariants: { variant: "default", }, },);
// Nothing inherits, so the text colour lives here rather than on the badge.// `whitespace-nowrap` is the base's, restated: Roblox wraps nothing by default,// but `TextSlot` is where the token has to land to reach a `TextLabel`.export const badgeLabelVariants = fv("whitespace-nowrap text-xs font-medium", { variants: { variant: { default: "text-primary-foreground", secondary: "text-secondary-foreground", // shadcn writes a literal `text-white` here, and this is the one place // this pass does not follow it. Tailwind v4's theme dropped // `--destructive-foreground`, so white is what shadcn has left; Facet's // theme still defines the role, and it resolves to the same near-white. // Naming the role keeps a retheme a config edit, which is the rule a // literal colour would break. See AGENTS.md, "Layer boundaries". destructive: "text-destructive-foreground", outline: "text-foreground", }, }, defaultVariants: { variant: "default", },});
export type BadgeProps = VariantProps<typeof badgeVariants> & { Text?: string; children?: React.ReactNode;} & PassthroughProps<Frame>;
const OWN_PROPS = ["variant", "className", "Text", "children"] as const;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export function Badge(props: BadgeProps) { const passthrough = getPassthroughProps<Frame>(props, OWN_PROPS);
return ( <frame className={cn(badgeVariants({ variant: props.variant, className: props.className }))} {...NEUTRAL_PROPS} {...passthrough} > <TextSlot Text={props.Text} className={badgeLabelVariants({ variant: props.variant })}> {props.children} </TextSlot> </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.
Props
| Prop | Type | Description |
|---|---|---|
| Text | string | The label. Drawn as a styled child textlabel through TextSlot; children renders instead when it is absent. |
| variant | "default" | "secondary" | "destructive" | "outline" | Surface and label colour. Defaults to default. |
| 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. |
| children | React.ReactNode | Composition — an icon glyph, a dot. Laid out in a row with gap-1 alongside nothing else, since Text and children are exclusive. |
Everything else is forwarded onto the Frame.
Variants
variant | Surface | Label |
|---|---|---|
default | bg-primary | text-primary-foreground |
secondary | bg-secondary | text-secondary-foreground |
destructive | bg-destructive | text-destructive-foreground |
outline | border border-input | text-foreground |
Both recipes are exported — badgeVariants and badgeLabelVariants — because nothing inherits and
the label’s colour has to live on the instance that draws the text.
size-fit is the whole geometry
export const badgeVariants = fv( "flex-row items-center justify-center gap-1 size-fit rounded-full px-2 py-1", { variants: { … }, defaultVariants: { variant: "default" } },);There is no h-* and no w-* here. size-fit resolves both axes to automatic sizing, which is the
only correct answer for a pill that has to be exactly as wide as its text plus its padding. The rule
it satisfies is declare both axes
— size-fit is an answer on each, not an absence of one.
That also makes the badge the clearest case of the AutomaticSize chain: it can only measure itself
because TextSlot renders its label with size-fit too. A label with an unresolved axis and the
badge collapses with it.
rounded-full is a pill rather than a rounded rectangle; px-2 py-1 insets the label without
growing the frame, which is exactly what UIPadding does and exactly why the frame needs size-fit
to grow around it.