Skip to content

Input

Single-line text entry with leading and trailing slots, three sizes and a shared control surface used by every other text control.

Formsstarterformtextemailprefixsuffix

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 { AtSign, DollarSign } from 'lucide-react'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function InputDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="workspace"
          label="Workspace name"
          hint="Lowercase letters and hyphens only."
          required
        >
          {(field) => <Input {...field} placeholder="acme-platform" defaultValue="acme-platform" />}
        </Field>

        <Field name="email" label="Work email" required>
          {(field) => (
            <Input
              {...field}
              type="email"
              inputMode="email"
              autoComplete="email"
              placeholder="you@company.com"
              leading={<AtSign className="size-4" />}
            />
          )}
        </Field>

        <Field name="seat-price" label="Price per seat" hint="Billed monthly, in USD.">
          {(field) => (
            <Input
              {...field}
              type="text"
              inputMode="decimal"
              defaultValue="18.00"
              leading={<DollarSign className="size-4" />}
              trailing={<span className="text-xs text-ink-subtle">/ mo</span>}
            />
          )}
        </Field>

        <Field
          name="slug"
          label="Public slug"
          error="That slug is already taken in this organisation."
        >
          {(field) => <Input {...field} defaultValue="acme" />}
        </Field>

        <Field name="domain" label="Verified domain" success="Domain verified 3 minutes ago.">
          {(field) => <Input {...field} defaultValue="acme.com" />}
        </Field>

        <Field name="account-id" label="Account ID" hint="Generated when the account was created.">
          {(field) => (
            <Input {...field} readOnly defaultValue="acc_4f9c21be" className="font-mono" />
          )}
        </Field>

        <Field name="legacy" label="Legacy identifier">
          {(field) => <Input {...field} disabled defaultValue="Not available on this plan" />}
        </Field>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/input.tsx

import { AtSign, DollarSign } from 'lucide-react'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { DemoColumn, DemoStage } from './_kit'

export default function InputDemo() {
  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="workspace"
          label="Workspace name"
          hint="Lowercase letters and hyphens only."
          required
        >
          {(field) => <Input {...field} placeholder="acme-platform" defaultValue="acme-platform" />}
        </Field>

        <Field name="email" label="Work email" required>
          {(field) => (
            <Input
              {...field}
              type="email"
              inputMode="email"
              autoComplete="email"
              placeholder="you@company.com"
              leading={<AtSign className="size-4" />}
            />
          )}
        </Field>

        <Field name="seat-price" label="Price per seat" hint="Billed monthly, in USD.">
          {(field) => (
            <Input
              {...field}
              type="text"
              inputMode="decimal"
              defaultValue="18.00"
              leading={<DollarSign className="size-4" />}
              trailing={<span className="text-xs text-ink-subtle">/ mo</span>}
            />
          )}
        </Field>

        <Field
          name="slug"
          label="Public slug"
          error="That slug is already taken in this organisation."
        >
          {(field) => <Input {...field} defaultValue="acme" />}
        </Field>

        <Field name="domain" label="Verified domain" success="Domain verified 3 minutes ago.">
          {(field) => <Input {...field} defaultValue="acme.com" />}
        </Field>

        <Field name="account-id" label="Account ID" hint="Generated when the account was created.">
          {(field) => (
            <Input {...field} readOnly defaultValue="acc_4f9c21be" className="font-mono" />
          )}
        </Field>

        <Field name="legacy" label="Legacy identifier">
          {(field) => <Input {...field} disabled defaultValue="Not available on this plan" />}
        </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}
    />
  )
}

components/ui/field.tsx

import type { ReactNode } from 'react'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { cn } from '@/lib/cn'

/**
 * Field
 *
 * The accessibility contract for every form control in Foundry lives here, in
 * one place, rather than being re-implemented per input.
 *
 * Ids are derived deterministically from `name` instead of `useId`, for two
 * reasons: the component stays renderable from a Server Component (no hooks),
 * and the markup is byte-identical between server and client, so a form works
 * before hydration — see `/docs/accessibility`.
 *
 * The render prop hands back the exact wiring a control needs, which makes it
 * impossible to forget `aria-describedby` on a field that has help text.
 */
export interface FieldRenderProps {
  id: string
  name: string
  'aria-describedby': string | undefined
  'aria-invalid': true | undefined
  'aria-required': true | undefined
  required: boolean
  invalid: boolean
}

export interface FieldProps {
  name: string
  label: ReactNode
  hint?: ReactNode
  /** Validation failure. Presence flips the field into its invalid state. */
  error?: string | null
  /** Confirmation message shown once a value has validated. */
  success?: string | null
  required?: boolean
  /** Marks the field "Optional" instead of marking required fields. */
  showOptional?: boolean
  /** Visually hides the label while keeping it available to screen readers. */
  hideLabel?: boolean
  className?: string
  /** Disambiguates ids when the same field name appears twice on one page. */
  idPrefix?: string
  children: (props: FieldRenderProps) => ReactNode
}

export function Field({
  name,
  label,
  hint,
  error,
  success,
  required = false,
  showOptional = false,
  hideLabel = false,
  className,
  idPrefix = 'field',
  children,
}: FieldProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const successId = success && !error ? `${id}-success` : undefined
  const describedBy = [hintId, errorId, successId].filter(Boolean).join(' ') || undefined

  return (
    <div className={cn('flex flex-col gap-1.5', className)}>
      <label
        htmlFor={id}
        className={cn(
          'flex items-baseline gap-1.5 text-sm font-medium text-ink',
          hideLabel && 'sr-only',
        )}
      >
        <span>{label}</span>
        {required && !showOptional ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
        {showOptional && !required ? (
          <span className="text-xs font-normal text-ink-subtle">Optional</span>
        ) : null}
      </label>

      {hint ? (
        <p id={hintId} className="text-xs leading-normal text-ink-muted">
          {hint}
        </p>
      ) : null}

      {children({
        id,
        name,
        'aria-describedby': describedBy,
        'aria-invalid': error ? true : undefined,
        'aria-required': required || undefined,
        required,
        invalid: Boolean(error),
      })}

      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}

      {success && !error ? (
        <p id={successId} className="flex items-start gap-1.5 text-xs font-medium text-success">
          <CheckCircle2 className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{success}</span>
        </p>
      ) : null}
    </div>
  )
}

/** Groups related controls (radios, checkboxes) with a shared legend. */
export interface FieldsetProps {
  legend: ReactNode
  hint?: ReactNode
  error?: string | null
  name: string
  required?: boolean
  className?: string
  children: ReactNode
  idPrefix?: string
}

export function Fieldset({
  legend,
  hint,
  error,
  name,
  required = false,
  className,
  children,
  idPrefix = 'group',
}: FieldsetProps) {
  const id = `${idPrefix}-${name}`
  const hintId = hint ? `${id}-hint` : undefined
  const errorId = error ? `${id}-error` : undefined
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined

  return (
    <fieldset
      className={cn('flex min-w-0 flex-col gap-2', className)}
      aria-describedby={describedBy}
      aria-invalid={error ? true : undefined}
      aria-required={required || undefined}
    >
      <legend className="flex items-baseline gap-1.5 text-sm font-medium text-ink">
        <span>{legend}</span>
        {required ? (
          <span className="text-danger" aria-hidden="true">
            *
          </span>
        ) : null}
      </legend>
      {hint ? (
        <p id={hintId} className="text-xs text-ink-muted">
          {hint}
        </p>
      ) : null}
      {children}
      {error ? (
        <p id={errorId} className="flex items-start gap-1.5 text-xs font-medium text-danger">
          <AlertCircle className="mt-px size-3.5 shrink-0" aria-hidden="true" />
          <span>{error}</span>
        </p>
      ) : null}
    </fieldset>
  )
}

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

Usage

The workhorse of the form system. Its border, radius, height and invalid treatment are exported as `controlSurface` and reused by Select, Textarea, SearchField, DateField and Combobox, so the whole family stays visually identical.

  • Set `type` and `inputMode` deliberately — `inputMode="decimal"` on a price field is the difference between a usable and an unusable mobile form.
  • Adornments are decorative. Anything a user must read belongs in the label or help text.
  • Read-only differs from disabled: read-only values are still focusable, copyable and submitted.

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.

  • Plain text entry
  • Leading icon — for email, search and identifiers
  • Trailing adornment — units, currency suffixes
  • Read-only — value matters but cannot change here
  • Disabled — unavailable in this context
  • Invalid — bordered and tinted, wired by Field
  • Small, medium and large heights

Accessibility

Naming
Inputs are never labelled by placeholder text — a placeholder disappears the moment typing begins.
Autofill
Set `autoComplete` on identity fields so password managers and browser autofill work.
Invalid
`aria-invalid` is applied by Field; the styling hangs off the attribute rather than a class, so the two can never disagree.

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.