npx facet-rbxts add textareaCopies ui/textarea.tsx, plus lib/utils.ts. Needs @facet-ui/react-variants,
@lattice-ui/react-runtime@^0.8.0 and @lattice-ui/react-textarea@^0.8.0.
import { Textarea, TextareaDescription, TextareaInput, TextareaLabel,} from "../shared/ui/textarea";
<Textarea maxRows={5} minRows={3} onValueCommit={submitReport}> <TextareaLabel Text="What happened?" /> <TextareaInput /> <TextareaDescription Text="A moderator reads this — keep it to what you saw." /></Textarea>import React from "@rbxts/react";import { Textarea, TextareaDescription, TextareaInput, TextareaLabel,} from "../ui/textarea";import { MODE } from "../facet-mode";
/** * The input recipe declares no height at all, which everywhere else in the * registry would collapse the box. Here Lattice owns `Size.Y` and grows it * between `minRows` and `maxRows` as the text wraps — type into it and watch. */export function ReportBox() { return ( <Textarea defaultValue="They kept blocking the shop door." maxRows={5} minRows={3}> <TextareaLabel Text="What happened?" /> <TextareaInput /> <TextareaDescription Text="A moderator reads this — keep it to what you saw." /> </Textarea> );}import { fv } from "@facet-ui/react-variants";import { getPassthroughProps, type PassthroughProps, React, toSlotProps } from "@lattice-ui/react-runtime";import { Textarea as TextareaPrimitive } from "@lattice-ui/react-textarea";import { type ClassName, cn } from "~/lib/utils";
/** * The input recipe declares only `w-full` — no height, which everywhere else * would collapse the frame. Here it is deliberate: Lattice's `Textarea.Input` * owns `Size.Y`, growing it line by line between `minRows` and `maxRows` as the * text wraps. Declaring a height class would just be overwritten on the next * keystroke. * * One recipe object rather than four exports: every exported name costs a * Luau register once Vela inlines its runtime. See * docs/decisions/luau-register-limit.md. */export const textareaVariants = { root: fv("flex-col gap-2 w-full h-fit"), input: fv( "w-full min-h-16 rounded-md border border-input shadow-sm px-3 py-2 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring", ), label: fv("w-full h-fit text-left text-sm font-medium text-foreground"), description: fv("w-full h-fit whitespace-normal leading-normal text-left text-sm font-normal text-muted-foreground"),};
export type TextareaProps = { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; /** Fires when the box loses focus, with the text as it stands. */ onValueCommit?: (value: string) => void; disabled?: boolean; readOnly?: boolean; invalid?: boolean; /** Grow with the text. On by default; the box then sizes between `minRows` and `maxRows`. */ autoResize?: boolean; minRows?: number; maxRows?: number; className?: ClassName; children?: React.ReactNode;} & PassthroughProps<Frame>;
export type TextareaInputProps = { disabled?: boolean; invalid?: boolean; className?: ClassName;} & PassthroughProps<TextBox>;
export type TextareaTextProps = { Text?: string; className?: ClassName } & PassthroughProps<TextLabel>;export type TextareaLabelProps = { Text?: string; className?: ClassName } & PassthroughProps<TextButton>;
// The forwarded bag is widened by `toSlotProps` and then has `children`// dropped from its *type*: these parts type `children` as a single element// (what `asChild` merges onto), and the bag never actually carries one —// `children` is listed as an own prop — so only the type needs narrowing.function forwardProps(props: object, ownKeys: readonly string[]): { key?: React.Key } & { [index: string]: unknown } { return toSlotProps(getPassthroughProps(props, ownKeys));}
const ROOT_OWN_PROPS = [ "value", "defaultValue", "onValueChange", "onValueCommit", "disabled", "readOnly", "invalid", "autoResize", "minRows", "maxRows", "className", "children",] as const;const INPUT_OWN_PROPS = ["disabled", "invalid", "className", "children"] as const;const TEXT_OWN_PROPS = ["Text", "className", "children"] as const;
const NEUTRAL_PROPS = { BackgroundTransparency: 1, BorderSizePixel: 0,};
export function Textarea(props: TextareaProps) { return ( <TextareaPrimitive.Root autoResize={props.autoResize} defaultValue={props.defaultValue} disabled={props.disabled} invalid={props.invalid} maxRows={props.maxRows} minRows={props.minRows} onValueChange={props.onValueChange} onValueCommit={props.onValueCommit} readOnly={props.readOnly} value={props.value} > <frame className={cn(textareaVariants.root({ className: props.className }))} {...NEUTRAL_PROPS} {...getPassthroughProps<Frame>(props, ROOT_OWN_PROPS)} > {props.children} </frame> </TextareaPrimitive.Root> );}
export function TextareaInput(props: TextareaInputProps) { const disabled = props.disabled === true;
return ( <TextareaPrimitive.Input className={textareaVariants.input({ className: cn(props.invalid === true && "border-destructive", disabled && "opacity-50", props.className), })} disabled={props.disabled} {...forwardProps(props, INPUT_OWN_PROPS)} /> );}
export function TextareaLabel(props: TextareaLabelProps) { return ( <TextareaPrimitive.Label className={cn(textareaVariants.label({ className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, TEXT_OWN_PROPS)} /> );}
export function TextareaDescription(props: TextareaTextProps) { return ( <TextareaPrimitive.Description className={cn(textareaVariants.description({ className: props.className }))} Text={props.Text ?? ""} {...forwardProps(props, TEXT_OWN_PROPS)} /> );}The parts
| Part | Renders | Classes |
|---|---|---|
Textarea | Frame | flex-col gap-2 w-full h-fit |
TextareaInput | TextBox | w-full rounded-md border border-input px-3 py-2 text-left text-sm font-normal text-foreground placeholder-muted-foreground focus:border-ring |
TextareaLabel | TextButton | w-full h-fit text-left text-sm font-medium text-foreground |
TextareaDescription | TextLabel | w-full h-fit whitespace-normal text-left text-xs font-normal text-muted-foreground |
Four parts, not five — there is no TextareaMessage. A multi-line box that needs a validation line
can reach for TextFieldMessage, or the file is yours to add one.
Renders a TextBox. Unknown props forward onto it and are type-checked against it, so a prop TextBox does not accept is a compile error. The primitive owns Size on the Y axis, which it grows with the text, so values you pass for it are ignored.
The input declares no height
input: fv( "w-full rounded-md border border-input px-3 py-2 …",),Rule 1 of the conventions is declare both axes, always — a Roblox instance with an unresolved axis renders at zero and takes the row above it with it. This recipe breaks that rule on purpose, and it is the only one that does.
Textarea.Input owns Size.Y. It measures the wrapped text and sets the height between minRows
and maxRows on every keystroke. A height class here would not lose an argument with the primitive —
it would be overwritten a frame later, which is worse, because the first render would look right.
Props
Textarea
| Prop | Type | Description |
|---|---|---|
| value | string | Controlled value. Pass it with onValueChange. |
| defaultValue | string | Uncontrolled starting text. |
| onValueChange | (value: string) => void | Fires on every keystroke. |
| onValueCommit | (value: string) => void | Fires when the box loses focus, with the text as it stands. |
| autoResize | boolean | Grow with the text. On by default; the box then sizes between minRows and maxRows. |
| minRows | number | Floor for the grown height. |
| maxRows | number | Ceiling. Past it the box scrolls instead. |
| disabled | boolean | Blocks focus and typing. |
| readOnly | boolean | Focusable, not editable. |
| invalid | boolean | Carries the state into Lattice's context. The red border comes from the input's own invalid. |
| className | ClassName | Threaded into the root 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. |
TextareaInput
| Prop | Type | Description |
|---|---|---|
| invalid | boolean | Adds border-destructive. |
| disabled | boolean | Adds opacity-50. |
| className | ClassName | Threaded into the input recipe's className slot inside the component. |
invalid is stated on the root and on the input, for
the reason Text field spells out:
nothing inherits, so the context half and the appearance half are two different props on two
different instances.
autoResize off
With autoResize={false} the primitive stops touching Size.Y, and the recipe’s missing height
becomes the problem rule 1 warns about: give the input one.
This is the rare case where a call-site className is the right place to do it. A class written
there is resolved at that call site and arrives as instance properties, which the component’s own
recipe then overwrites for the properties it also names — and this recipe names no height, so
<TextareaInput className="h-24" /> survives. Anything the recipe does state, like w-full, does
not. The mechanism, and why it reads backwards from cn’s own composition rules, is in
overriding from the call site.