iZakcode
Components

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.

Share the goal, constraints, and the outcome you're aiming for.

Optional notes, links, or implementation details for reviewers.

"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-textarea

You can also install it directly by URL:

npx shadcn@latest add https://shadcn.izakcode.com/r/generic-textarea.json

Install the required shadcn primitives:

npx shadcn@latest add textarea field

Install peer dependencies:

npm install react-hook-form

Copy and paste the following code into your project.

components/ui/generic-textarea.tsx
"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 = GenericTextarea

Features

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 the generated label and helper text for quick form scaffolding.

Native textarea props like rows and resize classes are forwarded as-is.

"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.

nameAuto label
summarySummary
projectBriefProject Brief
internal_notesInternal Notes
release-notesRelease Notes
items.0.feedbackBodyFeedback 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.

AttributeValueDescription
[data-disabled]true | absentPresent when the textarea is disabled.
[data-invalid]true | absentPresent 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 via htmlFor={id}.
  • Required state. When required is true, the textarea receives aria-required="true" and the asterisk is exposed with aria-label="required".
  • Optional state. When optionalLabel is rendered, it is exposed as aria-label="optional".
  • Description and error. Description and validation errors are joined into aria-describedby when present.
  • Validation state. On error, the textarea receives aria-invalid="true" and the parent <Field> gets data-invalid for styling hooks.

Notes

Disabled state behavior

The disabled prop on GenericTextarea works differently from react-hook-form's native disabled option:

ApproachHTML disabledValue in formStateIncluded 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.

On this page