Generic Textarea
A reusable form textarea that integrates with react-hook-form via useFormContext. Supports label auto-generation, descriptions, error display, optional annotations, and forwarded refs.
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { FieldGroup } from "@/components/ui/field"
import { GenericTextarea } from "@/components/ui/generic-textarea"
interface FormValues {
projectSummary: string
additionalContext: string
}
export default function GenericTextareaDemo() {
const form = useForm<FormValues>({
defaultValues: {
projectSummary: "",
additionalContext: "",
},
})
function onSubmit(data: FormValues) {
console.log(data)
}
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<FieldGroup>
<GenericTextarea<FormValues>
name="projectSummary"
description="Share the goal, constraints, and the outcome you're aiming for."
placeholder="Summarize the work in a few sentences..."
rows={4}
required
/>
<GenericTextarea<FormValues>
name="additionalContext"
label="Additional context"
description="Optional notes, links, or implementation details for reviewers."
placeholder="Anything else the team should know?"
optionalLabel="Optional"
rows={5}
/>
</FieldGroup>
<Button type="submit" className="w-full">
Submit
</Button>
</form>
</FormProvider>
)
}Installation
If you have this registry configured in components.json:
{
"registries": {
"@izakcode": "https://shadcn.izakcode.com/r/{name}.json"
}
}Install the component with the shadcn CLI:
npx shadcn@latest add @izakcode/generic-textareaYou can also install it directly by URL:
npx shadcn@latest add https://shadcn.izakcode.com/r/generic-textarea.jsonInstall the required shadcn primitives:
npx shadcn@latest add textarea fieldInstall peer dependencies:
npm install react-hook-formCopy and paste the following code into your project.
"use client"
import * as React from "react"
import { FieldValues, Path, useFormContext } from "react-hook-form"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
type GenericTextareaProps<TFieldValues extends FieldValues> = Omit<
React.ComponentProps<"textarea">,
"name" | "onChange" | "onBlur"
> & {
name: Path<TFieldValues>
label?: React.ReactNode
description?: React.ReactNode
required?: boolean
/**
* Label appended to the field label when the field is not required.
* Pass `null` (or an empty string) to suppress the label entirely (useful for dense forms where
* every field is optional and the annotation would be redundant).
* Defaults to `null` - you must opt in by passing e.g. `optionalLabel="Optional"`.
*/
optionalLabel?: React.ReactNode | null
/**
* Visually disables the textarea without clearing its value on submit.
*
* Unlike passing `disabled` directly to react-hook-form's `register()`,
* this prop only sets the native `disabled` attribute on the `<textarea>`
* element. The field value is preserved in form state and included in
* submitted data.
*
* @default false
*/
disabled?: boolean
/** Forwarded to react-hook-form's `register()` onChange handler. */
onChange?: React.ChangeEventHandler<HTMLTextAreaElement>
/** Forwarded to react-hook-form's `register()` onBlur handler. */
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
}
/**
* Derives a human-readable label from a react-hook-form field path.
*
* - Splits on `.` and discards pure-numeric segments (array indices).
* - Falls back to the last segment if every segment is numeric.
* - Returns the generic string "Field" only as a last resort; callers should
* always supply an explicit `label` prop for paths that might resolve poorly.
*/
function getLabelFromName(name: string): string {
const segments = name.split(".")
// Prefer the last non-numeric segment so "items.0.firstName" -> "First Name"
const lastMeaningful = segments.findLast((s) => !/^\d+$/.test(s)) ?? segments.at(-1) ?? name
const formatted = lastMeaningful
.replace(/\[.*?\]/g, "")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([a-zA-Z])(\d+)/g, "$1 $2")
.replace(/[_-]/g, " ")
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
return formatted || "Field"
}
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
return (node) => {
for (const ref of refs) {
if (typeof ref === "function") {
ref(node)
} else if (ref != null) {
;(ref as React.RefObject<T | null>).current = node
}
}
}
}
/**
* A reusable form textarea that integrates with react-hook-form via `useFormContext`.
*
* Must be rendered inside a `FormProvider`. Supports label auto-generation,
* description, error display, and optional-field annotations while forwarding
* all other native `<textarea>` props.
*/
export function GenericTextarea<TFieldValues extends FieldValues>({
name,
ref,
id,
label,
className,
required,
optionalLabel = null,
disabled = false,
description,
onBlur,
onChange,
...props
}: GenericTextareaProps<TFieldValues>) {
const form = useFormContext<TFieldValues>()
if (!form) {
throw new Error(
"<GenericTextarea> must be rendered inside a react-hook-form <FormProvider>. " +
`No FormProvider was found in the component tree for field "${name}".`
)
}
const { register, formState } = form
const fieldName = name as string
const textareaId = id ?? fieldName
const fieldState = form.getFieldState(name, formState)
const fieldError = fieldState.error
const errorId = fieldError ? `${textareaId}-error` : undefined
const descriptionId = description ? `${textareaId}-description` : undefined
const shouldRenderOptionalLabel =
!required &&
optionalLabel != null &&
(typeof optionalLabel !== "string" || optionalLabel.trim().length > 0)
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ")
const { ref: registerRef, ...restRegistration } = register(name, {
onBlur,
onChange,
required,
})
return (
<Field data-disabled={disabled ? true : undefined} data-invalid={fieldError ? true : undefined}>
<FieldLabel htmlFor={textareaId} className="flex items-center gap-1">
{label ?? getLabelFromName(fieldName)}
{required ? (
<span className="text-destructive" aria-label="required">
*
</span>
) : shouldRenderOptionalLabel ? (
<span className="text-muted-foreground" aria-label="optional">
{optionalLabel}
</span>
) : null}
</FieldLabel>
<Textarea
id={textareaId}
ref={mergeRefs(ref, registerRef)}
className={cn(className)}
disabled={disabled}
aria-required={required || undefined}
aria-invalid={fieldError ? true : undefined}
aria-describedby={describedBy || undefined}
{...props}
{...restRegistration}
/>
{description ? <FieldDescription id={descriptionId}>{description}</FieldDescription> : null}
{fieldError ? <FieldError id={errorId} errors={[fieldError]} /> : null}
</Field>
)
}
export const GenericTextArea = GenericTextareaFeatures
Type-safe field paths
Uses Path<TFieldValues> so name autocompletes and refactors with your schema.
Auto-generated labels
Derives a human-readable label from the field name when no label prop is provided.
Built-in error display
Reads field state from useFormContext and renders FieldError automatically.
Optional annotations
Supports opt-in optional labels without changing validation rules.
Native textarea props
Forwards rows, maxLength, placeholder, autoComplete, and other native textarea attributes.
Forwarded refs
Merges external refs with react-hook-form's register ref for focus and blur control.
Usage
Import the component and render it inside a FormProvider:
import { FormProvider, useForm } from "react-hook-form"
import { GenericTextarea } from "@/components/ui/generic-textarea"
type MyFormValues = {
summary: string
}
function MyForm() {
const form = useForm<MyFormValues>({
defaultValues: { summary: "" },
})
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(console.log)}>
<GenericTextarea<MyFormValues> name="summary" rows={5} required />
<button type="submit">Submit</button>
</form>
</FormProvider>
)
}Examples
Basic
Auto-generated labels, explicit labels, descriptions, optional annotations, and native textarea props.
"use client"
import { FormProvider, useForm } from "react-hook-form"
import { Button } from "@/components/ui/button"
import { FieldGroup } from "@/components/ui/field"
import { GenericTextarea } from "@/components/ui/generic-textarea"
type ContactFormValues = {
projectBrief: string
internalNotes: string
}
export default function GenericTextareaBasic() {
const form = useForm<ContactFormValues>({
defaultValues: {
projectBrief: "",
internalNotes: "",
},
})
function onSubmit(data: ContactFormValues) {
console.log("Form submitted:", data)
}
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<FieldGroup>
<GenericTextarea<ContactFormValues>
name="projectBrief"
description="Use the generated label and helper text for quick form scaffolding."
placeholder="Describe the feature request or bug report..."
rows={5}
required
/>
<GenericTextarea<ContactFormValues>
name="internalNotes"
label="Internal notes"
description="Native textarea props like rows and resize classes are forwarded as-is."
placeholder="Private implementation notes"
optionalLabel="Optional"
rows={4}
className="resize-y"
/>
</FieldGroup>
<Button type="submit" className="w-full">
Submit
</Button>
</form>
</FormProvider>
)
}Auto-generated labels
When no label prop is provided, GenericTextarea derives one from the field path. Camel-case, snake-case, kebab-case, and array indices are all handled.
name | Auto label |
|---|---|
summary | Summary |
projectBrief | Project Brief |
internal_notes | Internal Notes |
release-notes | Release Notes |
items.0.feedbackBody | Feedback Body |
Auto-generation is a convenience for prototypes and obvious cases. Always pass an explicit label
for production fields where the field path may not translate cleanly.
With Zod validation
"use client"
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { FieldGroup } from "@/components/ui/field"
import { GenericTextarea } from "@/components/ui/generic-textarea"
const schema = z.object({
summary: z.string().min(20, "Please provide at least 20 characters."),
notes: z.string().max(500, "Keep notes under 500 characters.").optional(),
})
type FormValues = z.infer<typeof schema>
export default function GenericTextareaZod() {
const form = useForm<FormValues>({
resolver: standardSchemaResolver(schema),
defaultValues: { summary: "", notes: "" },
})
function onSubmit(data: FormValues) {
console.log(data)
}
return (
<FormProvider {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="mx-auto flex w-full max-w-md flex-col gap-4"
>
<FieldGroup>
<GenericTextarea<FormValues>
name="summary"
label="Summary"
placeholder="Explain the problem you're solving..."
rows={5}
required
/>
<GenericTextarea<FormValues>
name="notes"
label="Supporting notes"
placeholder="Optional supporting details"
optionalLabel="Optional"
rows={4}
/>
</FieldGroup>
<Button type="submit" className="w-full">
Save draft
</Button>
</form>
</FormProvider>
)
}Errors from the resolver are read via useFormContext().getFieldState(name, formState) and rendered with <FieldError /> automatically.
Nested and array field paths
name is typed as Path<TFieldValues>, so deeply nested paths and array indices are autocompleted and type-checked.
type FormValues = {
user: { profile: { summary: string } }
items: { notes: string }[]
}
<GenericTextarea<FormValues> name="user.profile.summary" rows={4} required />
<GenericTextarea<FormValues> name="items.0.notes" label="Line item notes" rows={3} />Forwarding a ref
GenericTextarea merges any externally supplied ref with react-hook-form's internal register ref using a mergeRefs helper.
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
<GenericTextarea<FormValues> ref={textareaRef} name="summary" rows={5} />
// Later:
textareaRef.current?.focus()API Reference
GenericTextarea extends every native <textarea> prop except name, onChange, and onBlur, which are managed via react-hook-form's register().
Prop
Type
In addition, any other native <textarea> prop, such as placeholder, maxLength, autoComplete, wrap, and readOnly, is forwarded to the underlying <Textarea> element.
Data attributes
These attributes are set on the wrapping <Field> and can be targeted from CSS.
| Attribute | Value | Description |
|---|---|---|
[data-disabled] | true | absent | Present when the textarea is disabled. |
[data-invalid] | true | absent | Present when the field has a validation error. |
Styling
GenericTextarea is unstyled beyond what shadcn/ui's Field and Textarea provide.
1. The className prop
className is merged via cn() onto the underlying <Textarea>.
<GenericTextarea<Values> name="notes" className="min-h-32 resize-y font-mono" />2. Tailwind on the surrounding markup
Wrap GenericTextarea in a layout container. Field already manages its internal rhythm, so prefer gap-* or grid utilities on the parent.
<div className="grid gap-4 md:grid-cols-2">
<GenericTextarea<Values> name="summary" rows={5} />
<GenericTextarea<Values> name="notes" rows={5} />
</div>3. Theming via shadcn/ui tokens
Colors are sourced from your shadcn/ui theme (--destructive, --muted-foreground, --foreground, and related tokens). Update your CSS variables and the component re-themes automatically.
4. Data-attribute selectors
Style invalid or disabled states centrally without prop drilling:
[data-slot="field"][data-invalid] [data-slot="textarea"] {
box-shadow: 0 0 0 2px var(--destructive);
}Accessibility
Built on shadcn's <Field /> primitives, which follow the WAI-ARIA form field authoring practices.
- Label association.
<FieldLabel>is wired to the textarea viahtmlFor={id}. - Required state. When
requiredistrue, the textarea receivesaria-required="true"and the asterisk is exposed witharia-label="required". - Optional state. When
optionalLabelis rendered, it is exposed asaria-label="optional". - Description and error. Description and validation errors are joined into
aria-describedbywhen present. - Validation state. On error, the textarea receives
aria-invalid="true"and the parent<Field>getsdata-invalidfor styling hooks.
Notes
Disabled state behavior
The disabled prop on GenericTextarea works differently from react-hook-form's native disabled option:
| Approach | HTML disabled | Value in formState | Included on submit |
|---|---|---|---|
GenericTextarea disabled | ✅ | ✅ Preserved | ✅ Yes |
RHF register({ disabled }) | ✅ | ❌ Set to undefined | ❌ No |
This is intentional. It lets you visually disable textarea fields while preserving values for pre-filled data or hidden defaults.