Forms

Composable form elements using Field/Label pattern — inputs, selects, checkboxes, radios, switches, range sliders, input groups, and validation states.

Preview

Text input

import { Field, Label, Description, Input } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <Field>
      <Label>Email</Label>
      <Input type="email" placeholder="you@example.com" />
      <Description>We'll never share your email.</Description>
    </Field>
  )
}

Select

import { Field, Label, Select } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <Field>
      <Label>Country</Label>
      <Select>
        <option value="us">United States</option>
        <option value="uk">United Kingdom</option>
      </Select>
    </Field>
  )
}

Checkbox

import { Checkbox } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <Checkbox
      label="Accept terms"
      description="You agree to our Terms of Service."
    />
  )
}

Radio group

import { RadioGroup } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <RadioGroup
      name="plan"
      options={[
        { value: 'free', label: 'Free', description: 'Up to 5 projects' },
        { value: 'pro', label: 'Pro', description: 'Unlimited projects' },
      ]}
    />
  )
}

Switch with label

import { Field, Label, Description, Switch } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  const [enabled, setEnabled] = useState(false)
  return (
    <Field>
      <div className="flex items-center justify-between">
        <Label>Notifications</Label>
        <Switch checked={enabled} onChange={setEnabled} />
      </div>
      <Description>Receive push notifications.</Description>
    </Field>
  )
}

Range slider

import { Field, Label, Range } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <Field>
      <Label>Volume</Label>
      <Range min={0} max={100} />
    </Field>
  )
}

Input group

Inputs with leading or trailing addons.

import { Field, Label, InputGroup } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <div className="space-y-4">
      <Field>
        <Label>Website</Label>
        <InputGroup leadingAddon="https://" placeholder="example.com" />
      </Field>
      <Field>
        <Label>Price</Label>
        <InputGroup trailingAddon="USD" placeholder="0.00" />
      </Field>
    </div>
  )
}

Validation

Use the invalid prop with ErrorMessage for error states.

import { Field, Label, ErrorMessage, Input } from '@/registry/ui-kit/tailwind-forms/react'

function Example() {
  return (
    <Field>
      <Label>Password</Label>
      <Input type="password" invalid />
      <ErrorMessage>Must be at least 8 characters.</ErrorMessage>
    </Field>
  )
}

Component API

PropDefaultDescription
FieldWrapper that provides shared id via context. Renders a div with space-y-1.5.
LabelReads id from Field context and renders a label element with htmlFor.
DescriptionHelper text below the input.
ErrorMessageError message text — renders in red.
invalidfalseTurns the border red on Input, Select, Textarea, and InputGroup.
shortcut"⌘K"Keyboard shortcut badge (SearchInput only).
leadingAddonLeft-side addon text (InputGroup only).
trailingAddonRight-side addon text (InputGroup only).
checkedfalseControlled checked state (Switch only).
optionsOptions array for RadioGroup.

Source Code

"use client";

import { forwardRef, createContext, useContext, useId } from "react";

/* ── Field Context ── */

const FieldContext = createContext<{ id?: string; error?: boolean }>({});

/* ── Field ── */

interface FieldProps {
  children: React.ReactNode;
  className?: string;
}

function Field({ children, className = "" }: FieldProps) {
  const id = useId();
  return (
    <FieldContext.Provider value={{ id }}>
      <div className={`space-y-1.5 ${className}`}>{children}</div>
    </FieldContext.Provider>
  );
}

/* ── Label ── */

interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
  children: React.ReactNode;
}

function Label({ children, className = "", ...props }: LabelProps) {
  const { id } = useContext(FieldContext);
  return (
    <label
      htmlFor={id}
      className={`block text-sm font-medium text-zinc-900 dark:text-zinc-100 ${className}`}
      {...props}
    >
      {children}
    </label>
  );
}

/* ── Description ── */

function Description({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return <p className={`text-sm text-zinc-500 dark:text-zinc-400 ${className}`}>{children}</p>;
}

/* ── ErrorMessage ── */

function ErrorMessage({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return <p className={`text-sm text-red-600 ${className}`}>{children}</p>;
}

/* ── Input ── */

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  invalid?: boolean;
}

const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ invalid = false, className = "", ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <input
        ref={ref}
        id={id}
        className={`block w-full rounded-lg border bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${
          invalid
            ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
            : "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
        } ${className}`}
        aria-invalid={invalid || undefined}
        {...props}
      />
    );
  }
);
Input.displayName = "Input";

/* ── Search Input ── */

interface SearchInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
  shortcut?: string;
}

const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
  ({ shortcut = "⌘K", className = "", ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <div className={`relative ${className}`}>
        <svg
          className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <circle cx="11" cy="11" r="8" />
          <path d="m21 21-4.3-4.3" />
        </svg>
        <input
          ref={ref}
          id={id}
          type="search"
          className="block w-full rounded-full border border-zinc-300 bg-white py-2 pl-10 pr-16 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
          {...props}
        />
        {shortcut && (
          <kbd className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 select-none rounded border border-zinc-200 bg-zinc-100 px-1.5 py-0.5 text-xs font-medium text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
            {shortcut}
          </kbd>
        )}
      </div>
    );
  }
);
SearchInput.displayName = "SearchInput";

/* ── Select ── */

interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
  invalid?: boolean;
}

const Select = forwardRef<HTMLSelectElement, SelectProps>(
  ({ invalid = false, className = "", children, ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <div className="relative">
        <select
          ref={ref}
          id={id}
          className={`block w-full appearance-none rounded-lg border bg-white py-2 pl-3.5 pr-10 text-sm text-zinc-900 shadow-sm outline-none transition-colors dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 ${
            invalid
              ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
              : "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
          } ${className}`}
          aria-invalid={invalid || undefined}
          {...props}
        >
          {children}
        </select>
        <svg
          className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
        >
          <path d="m6 9 6 6 6-6" />
        </svg>
      </div>
    );
  }
);
Select.displayName = "Select";

/* ── Textarea ── */

interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
  invalid?: boolean;
}

const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
  ({ invalid = false, className = "", ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <textarea
        ref={ref}
        id={id}
        className={`block w-full rounded-lg border bg-white px-3.5 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${
          invalid
            ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/20"
            : "border-zinc-300 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
        } ${className}`}
        aria-invalid={invalid || undefined}
        {...props}
      />
    );
  }
);
Textarea.displayName = "Textarea";

/* ── Checkbox ── */

interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
  label: string;
  description?: string;
}

const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
  ({ label, description, className = "", ...props }, ref) => {
    return (
      <label className={`flex cursor-pointer items-start gap-3 ${className}`}>
        <input
          ref={ref}
          type="checkbox"
          className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-zinc-300 text-indigo-600 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-900"
          {...props}
        />
        <div>
          <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{label}</span>
          {description && (
            <p className="text-sm text-zinc-500 dark:text-zinc-400">{description}</p>
          )}
        </div>
      </label>
    );
  }
);
Checkbox.displayName = "Checkbox";

/* ── Radio Group ── */

interface RadioOption {
  value: string;
  label: string;
  description?: string;
}

interface RadioGroupProps {
  name: string;
  options: RadioOption[];
  value?: string;
  onChange?: (value: string) => void;
  className?: string;
}

function RadioGroup({ name, options, value, onChange, className = "" }: RadioGroupProps) {
  return (
    <div className={`space-y-2.5 ${className}`}>
      {options.map((opt) => (
        <label key={opt.value} className="flex cursor-pointer items-start gap-3">
          <input
            type="radio"
            name={name}
            value={opt.value}
            checked={value === opt.value}
            onChange={() => onChange?.(opt.value)}
            className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer border-zinc-300 text-indigo-600 focus:ring-indigo-500/20 dark:border-zinc-600 dark:bg-zinc-900"
          />
          <div>
            <span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{opt.label}</span>
            {opt.description && (
              <p className="text-sm text-zinc-500 dark:text-zinc-400">{opt.description}</p>
            )}
          </div>
        </label>
      ))}
    </div>
  );
}

/* ── Switch / Toggle ── */

interface SwitchProps {
  checked?: boolean;
  onChange?: (checked: boolean) => void;
  disabled?: boolean;
  className?: string;
}

function Switch({ checked = false, onChange, disabled = false, className = "" }: SwitchProps) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      disabled={disabled}
      onClick={() => onChange?.(!checked)}
      className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 disabled:cursor-not-allowed disabled:opacity-50 ${
        checked ? "bg-indigo-600" : "bg-zinc-200 dark:bg-zinc-700"
      } ${className}`}
    >
      <span
        className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform ${
          checked ? "translate-x-4" : "translate-x-0"
        }`}
      />
    </button>
  );
}

/* ── Range Slider ── */

interface RangeProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {}

const Range = forwardRef<HTMLInputElement, RangeProps>(
  ({ className = "", ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <input
        ref={ref}
        type="range"
        id={id}
        className={`w-full cursor-pointer accent-indigo-600 ${className}`}
        {...props}
      />
    );
  }
);
Range.displayName = "Range";

/* ── Input Group ── */

interface InputGroupProps extends React.InputHTMLAttributes<HTMLInputElement> {
  leadingAddon?: string;
  trailingAddon?: string;
  invalid?: boolean;
}

const InputGroup = forwardRef<HTMLInputElement, InputGroupProps>(
  ({ leadingAddon, trailingAddon, invalid = false, className = "", ...props }, ref) => {
    const { id } = useContext(FieldContext);
    return (
      <div className={`flex overflow-hidden rounded-lg border shadow-sm ${invalid ? "border-red-500" : "border-zinc-300 dark:border-zinc-700"} focus-within:border-indigo-500 focus-within:ring-2 focus-within:ring-indigo-500/20`}>
        {leadingAddon && (
          <span className="flex items-center border-r border-zinc-300 bg-zinc-50 px-3.5 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
            {leadingAddon}
          </span>
        )}
        <input
          ref={ref}
          id={id}
          className={`block w-full bg-white px-3.5 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 dark:bg-zinc-900 dark:text-zinc-100 dark:placeholder:text-zinc-500 ${className}`}
          aria-invalid={invalid || undefined}
          {...props}
        />
        {trailingAddon && (
          <span className="flex items-center border-l border-zinc-300 bg-zinc-50 px-3.5 text-sm text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
            {trailingAddon}
          </span>
        )}
      </div>
    );
  }
);
InputGroup.displayName = "InputGroup";

export { Field, Label, Description, ErrorMessage, Input, SearchInput, Select, Textarea, Checkbox, RadioGroup, Switch, Range, InputGroup };
export default Input;