Skip to content

Textarea

Multi-line entry on the shared control surface, with a resize affordance that respects the layout.

Formsstarterformmultilinenotesdescription

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

import { Field } from '@/components/ui/field'
import { Textarea } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function TextareaDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field
          name="summary"
          label="Release summary"
          hint="Appears at the top of the changelog entry. Markdown is supported."
          required
        >
          {(field) => (
            <Textarea
              {...field}
              rows={4}
              placeholder="What changed, and who it matters to…"
              defaultValue="Adds density as a first-class theme axis. Every control height now resolves from a token, so compact admin surfaces and relaxed marketing pages share one component set."
            />
          )}
        </Field>

        <Field name="notes" label="Internal notes" error="Notes must be at least 20 characters.">
          {(field) => <Textarea {...field} rows={3} defaultValue="Too short" />}
        </Field>

        <Field name="archive" label="Archived description">
          {(field) => (
            <Textarea
              {...field}
              rows={3}
              disabled
              defaultValue="Locked while the release is published."
            />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/textarea.tsx

import { Field } from '@/components/ui/field'
import { Textarea } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function TextareaDemo() {
  return (
    <DemoStage>
      <DemoColumn width="lg">
        <Field
          name="summary"
          label="Release summary"
          hint="Appears at the top of the changelog entry. Markdown is supported."
          required
        >
          {(field) => (
            <Textarea
              {...field}
              rows={4}
              placeholder="What changed, and who it matters to…"
              defaultValue="Adds density as a first-class theme axis. Every control height now resolves from a token, so compact admin surfaces and relaxed marketing pages share one component set."
            />
          )}
        </Field>

        <Field name="notes" label="Internal notes" error="Notes must be at least 20 characters.">
          {(field) => <Textarea {...field} rows={3} defaultValue="Too short" />}
        </Field>

        <Field name="archive" label="Archived description">
          {(field) => (
            <Textarea
              {...field}
              rows={3}
              disabled
              defaultValue="Locked while the release is published."
            />
          )}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/input.tsx

import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
import { cn } from '@/lib/cn'

/**
 * Input / Textarea
 *
 * Shared control chrome lives in `controlSurface` so every text-entry control
 * in the library — including the combobox and OTP field — sits on exactly the
 * same border, radius, height and invalid treatment.
 */
export const controlSurface = cn(
  'w-full min-w-0 bg-surface text-ink placeholder:text-ink-subtle',
  'border border-line rounded-md',
  'transition-[border-color,background-color] duration-150 ease-standard',
  'hover:border-line-strong',
  'disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle disabled:hover:border-line',
  'aria-[invalid=true]:border-danger aria-[invalid=true]:bg-danger-soft',
  'read-only:bg-surface-sunken',
)

export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  inputSize?: 'sm' | 'md' | 'lg'
  /** Icon or text rendered inside the control on the leading edge. */
  leading?: ReactNode
  trailing?: ReactNode
}

const heights = {
  sm: 'h-control-sm text-xs',
  md: 'h-control text-sm',
  lg: 'h-control-lg text-base',
} as const

export function Input({ inputSize = 'md', leading, trailing, className, ...props }: InputProps) {
  const paddingX = inputSize === 'lg' ? 'px-3.5' : 'px-3'

  if (!leading && !trailing) {
    return (
      <input className={cn(controlSurface, heights[inputSize], paddingX, className)} {...props} />
    )
  }

  return (
    <div
      className={cn(
        'relative flex items-center',
        // The wrapper carries no border; the input keeps it so focus-visible
        // still lands on the real control.
      )}
    >
      {leading ? (
        <span
          className="pointer-events-none absolute left-3 flex items-center text-ink-subtle"
          aria-hidden="true"
        >
          {leading}
        </span>
      ) : null}
      <input
        className={cn(
          controlSurface,
          heights[inputSize],
          paddingX,
          leading && 'pl-9',
          trailing && 'pr-9',
          className,
        )}
        {...props}
      />
      {trailing ? (
        <span className="absolute right-2.5 flex items-center text-ink-subtle">{trailing}</span>
      ) : null}
    </div>
  )
}

export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
  /** Grows with content up to this many rows before scrolling. */
  rows?: number
}

export function Textarea({ rows = 4, className, ...props }: TextareaProps) {
  return (
    <textarea
      rows={rows}
      className={cn(controlSurface, 'resize-y px-3 py-2 text-sm leading-normal', className)}
      {...props}
    />
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For free-form prose: descriptions, release notes, support messages. Vertical-only resize prevents a user dragging the control wider than its column and breaking the layout.

  • Size `rows` to the expected answer. A three-line box invites three lines.
  • Pair long-form fields with a character or word budget in the help text rather than a hard `maxLength` that silently truncates.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Default rows
  • Invalid
  • Disabled
  • Vertical resize only

Accessibility

Resizing
Resize is not disabled — removing it takes away a genuine accessibility affordance for users with low vision.
Description
Help text and errors are associated by Field exactly as they are for Input.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.