npx facet-rbxts add kbdCopies ui/kbd.tsx, plus lib/utils.ts and lib/text.tsx. Needs @facet-ui/react-variants and
@lattice-ui/react-runtime.
import { Kbd } from "../shared/ui/kbd";
<Kbd Text="E" /><Kbd Text="Ctrl" /><Kbd Text="⌘" />import React from "@rbxts/react";import { Kbd } from "../ui/kbd";import { Label } from "../ui/label";import { MODE } from "../facet-mode";
/** * A single letter beside a word, which is the thing `size-fit` is doing here — * the caps are different widths because their contents are. */export function Kbds() { return ( <frame className="flex-col gap-2 w-full h-fit"> <frame className="flex-row items-center gap-2 w-full h-fit"> <Kbd Text="E" /> <Label Text="Interact" /> </frame> <frame className="flex-row items-center gap-2 w-full h-fit"> <Kbd Text="Ctrl" /> <Kbd Text="R" /> <Label Text="Reset character" /> </frame> </frame> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React } from "@lattice-ui/react-runtime";import { TextSlot } from "~/lib/text";import { type ClassName, cn } from "~/lib/utils";
/** * A key cap — `Ctrl`, `⌘`, `E`. * * Fixed height, fitted width, and a floor under the width: `h-5 w-fit min-w-5` * is shadcn's, and it is what makes `E` a square and `Backspace` a lozenge from * the same recipe. Vela lowers `min-w-5` onto a `UISizeConstraint`. * * No border. shadcn's cap is a filled `bg-muted` block and nothing more — the * outline this file used to draw was an invention. */export const kbdVariants = fv( "flex-row items-center justify-center gap-1 w-fit h-5 min-w-5 rounded-sm bg-muted px-1 pointer-events-none",);
/** * `font-sans` is a family, not a weight — Vela resolves it to SourceSansPro * against `theme.fontFamily`, where the weights resolve against something else * entirely. shadcn names it explicitly here because a `<kbd>` element is * monospace by user-agent default and the cap is not meant to be; Roblox has no * such default, so the token is carried over for the meaning rather than the * correction. */export const kbdLabelVariants = fv("whitespace-nowrap text-xs font-sans font-medium text-muted-foreground");
/** A run of caps read as one shortcut — `Ctrl` `Shift` `P`. */export const kbdGroupVariants = fv("flex-row items-center gap-1 size-fit");
export type KbdProps = { className?: ClassName; Text?: string; children?: React.ReactNode } & PassthroughProps<Frame>;
export type KbdGroupProps = { className?: ClassName; children?: React.ReactNode } & PassthroughProps<Frame>;
const OWN_PROPS = ["className", "Text", "children"] as const;const GROUP_OWN_PROPS = ["className", "children"] as const;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export function Kbd(props: KbdProps) { return ( <frame className={cn(kbdVariants({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, OWN_PROPS)} > <TextSlot Text={props.Text} className={kbdLabelVariants()}> {props.children} </TextSlot> </frame> );}
export function KbdGroup(props: KbdGroupProps) { return ( <frame className={cn(kbdGroupVariants({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, GROUP_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.
Props
| Prop | Type | Description |
|---|---|---|
| Text | string | The key. Drawn as a styled child textlabel through TextSlot; children renders instead when it is absent. |
| 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, when the cap holds something that is not a string. Exclusive with Text. |
Everything else is forwarded onto the Frame.
font-mono is a family, not a weight
export const kbdLabelVariants = fv("text-xs font-mono text-muted-foreground");Every other component carries a font-* token for a defensive reason: Vela leaves FontFace alone
when no font-* appears, and Roblox’s untouched default is LegacyArial. So font-normal and
font-medium elsewhere are there to avoid a default, and they name a weight.
font-mono is not that. Vela resolves it against theme.fontFamily — to RobotoMono — where the
weights resolve against something else entirely. This is the one place in the registry where the
mandatory font-* is choosing a typeface because the component wants that typeface.
A key cap in a proportional face reads as a word in a box. In a monospace face it reads as a key. That is the whole justification, and it is worth the one exception.
See Text and labels for why font-* is mandatory at all.
size-fit with padding, not a fixed square
export const kbdVariants = fv( "flex-row items-center justify-center size-fit rounded-md border border-border bg-muted px-1.5 py-0.5",);The obvious implementation of a key cap is a square — keys are square. It is wrong here, because a
cap has to hold Ctrl as readily as E, and Roblox will not infer that width for you.
size-fit plus px-1.5 gives a cap that is as wide as its content and no wider, so E comes out
nearly square and Ctrl comes out a rounded rectangle. Same recipe, no variants, no w-*.
That makes kbd the same AutomaticSize story as Badge:
the frame can only measure itself because TextSlot renders its label with size-fit too. Break
that and the cap collapses to nothing.
If you want uniform caps in a row — a shortcut legend where the columns should line up — <Kbd className="w-10" Text="E" /> will not do it: a class written at a Vela-compiled call site
never reaches the component.
Add a size variant to your copy of the file, or wrap each cap in a fixed-width frame.
What it does not do
There is no size variant and no pressed state. A key cap is a static label for a key that exists
on a keyboard; a thing that responds to being clicked is a
Button. Composing them — a cap inside a button’s children — is the
supported way to build a rebindable-key row, and it keeps the interaction in the component that
already handles it.