uiatom
Textarea
A multi-line text field built on Base UI's Field, so the label, help text and error message are connected to the control. Same border and focus ring as Input.
Preview
Installation
pnpm dlx shadcn@latest add @facade/textareaThis component needs @base-ui-components/react@1.0.0-rc.0, utils. The CLI installs them for you.
Usage
This is the code for the preview above. To install it as a file, run npx shadcn@latest add @facade/textarea-demo.
"use client"
/** `"use client"` only for the submit handler that keeps the demo on the page. */
import { Button } from "@registry/ui/button"
import { Input } from "@registry/ui/input"
import { Textarea } from "@registry/ui/textarea"
export function Demo() {
return (
<form
onSubmit={(event) => event.preventDefault()}
noValidate
className="flex max-w-md flex-col gap-5"
>
<Input label="Name" name="name" autoComplete="name" required />
<Textarea
label="Message"
name="message"
rows={5}
description="Tell us what you are building."
required
/>
<Textarea
label="Notes"
name="notes"
rows={2}
resize="none"
defaultValue="Please call after 3pm, or before 9am on weekdays, or any time at weekends."
error="Keep notes under 60 characters."
/>
<Button type="submit" className="self-start">
Send
</Button>
</form>
)
}Source
These are the files the CLI copies into your project.
/**
* Textarea — a multi-line text field built on Base UI's Field, so the label,
* description and error are wired to the control the same way `Input` wires
* them.
*
* Base UI's `Input` is `Field.Control`; here `Field.Control` renders a
* `<textarea>` through its `render` prop, which keeps the generated ids, the
* `aria-*` links and the validation ref.
*
* a11y notes worth keeping:
*
* - The border uses `--input`, held at 3:1 against the background.
* - `Field.Root invalid` marks the control `aria-invalid` and turns the
* border red for an error passed in from outside, such as one from a server.
* - "required" is spelled out in the label, as in `Input`.
* - At least 44px tall, and it can be resized vertically unless `resize` is
* `none`, so long answers stay readable without a scrollbar.
*
* Dependencies: @base-ui-components/react, react, @/lib/utils.
*/
import { Field } from "@base-ui-components/react/field"
import type { ComponentPropsWithoutRef, ReactNode } from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps extends ComponentPropsWithoutRef<"textarea"> {
/** Required. Use `hideLabel` if it should not be visible. */
label: ReactNode
/** Hides the label visually but keeps it for screen readers. */
hideLabel?: boolean
/** Help text below the field. It is linked to the field for you. */
description?: ReactNode
/** Error message. Marks the field invalid and is announced with it. */
error?: ReactNode
/** Visible lines of text. */
rows?: number
/** `vertical` lets the user drag the height; `none` fixes it. */
resize?: "vertical" | "none"
/** Classes for the outer Field.Root. */
className?: string
/** Classes for the `<textarea>` itself. */
textareaClassName?: string
}
export function Textarea({
label,
hideLabel = false,
description,
error,
rows = 4,
resize = "vertical",
className,
textareaClassName,
name,
value,
defaultValue,
disabled,
required,
...props
}: TextareaProps) {
return (
<Field.Root
name={name}
invalid={Boolean(error)}
className={cn("flex w-full flex-col gap-1.5", className)}
>
<Field.Label
className={cn("text-foreground text-sm font-medium", hideLabel && "sr-only")}
>
{label}
{required ? <span className="text-muted-foreground"> (required)</span> : null}
</Field.Label>
{/* Field reads the form props; the rest are textarea attributes. */}
<Field.Control
name={name}
value={value}
defaultValue={defaultValue}
disabled={disabled}
required={required}
render={<textarea rows={rows} {...props} />}
className={cn(
"border-input bg-background text-foreground placeholder:text-muted-foreground",
"min-h-11 w-full rounded-md border px-3.5 py-2.5 text-base leading-6",
"duration-facade-fast ease-facade-out transition-[border-color,box-shadow]",
"focus-visible:ring-ring focus-visible:border-ring outline-none focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-60",
"data-[invalid]:border-destructive data-[invalid]:focus-visible:ring-destructive",
resize === "none" ? "resize-none" : "resize-y",
textareaClassName,
)}
/>
{description ? (
<Field.Description className="text-muted-foreground text-pretty text-sm">
{description}
</Field.Description>
) : null}
{/* `match` shows an error from outside, which Base UI's own validity
state knows nothing about. Without one, no children are passed, so
Base UI shows the browser's own message for a failed check. */}
<Field.Error
className="text-destructive text-pretty text-sm"
match={Boolean(error) || undefined}
{...(error ? { children: error } : {})}
/>
</Field.Root>
)
}Props
TextareaProps
Extends ComponentPropsWithoutRef<"textarea">.
| Prop | Type | Default |
|---|---|---|
label*RequiredRequired. Use `hideLabel` if it should not be visible. | ReactNode | — |
hideLabelHides the label visually but keeps it for screen readers. | boolean | false |
descriptionHelp text below the field. It is linked to the field for you. | ReactNode | — |
errorError message. Marks the field invalid and is announced with it. | ReactNode | — |
rowsVisible lines of text. | number | 4 |
resize`vertical` lets the user drag the height; `none` fixes it. | "vertical" | "none" | "vertical" |
classNameClasses for the outer Field.Root. | string | — |
textareaClassNameClasses for the `<textarea>` itself. | string | — |