Skip to content
Seamless UI

Input

Save

A text field with a label, helper text, error and busy states, wired up for screen readers.

Category
Core
Version
0.1.0
Frameworks
Next.js, Vite + React
Styling
Tailwind CSS v4

Preview

Input preview

Shown to everyone on your team.

Enter a complete email address.

Checking availability…

Installation

npx @seamlessui/cli add input

Pre-release@seamlessui/cli is not on npm yet. Install from a local build.

Review the plan first. A dry run lists every file and package without changing anything:

npx @seamlessui/cli add input --dry-run

Usage

input-example.tsx
import { Input } from "@/components/seamless/ui/input";

export default function InputExample() {
  return (
    <form className="grid w-full max-w-sm gap-4">
      <Input label="Workspace name" placeholder="Acme" description="Shown to everyone on your team." />
      <Input label="Email" type="email" defaultValue="ada@" error="Enter a complete email address." />
      <Input label="Subdomain" defaultValue="acme" busy description="Checking availability…" />
      <Input label="Plan" defaultValue="Pro" disabled />
    </form>
  );
}

API

PropTypeDefaultDescription
labelReactNode—Visible label, linked to the input. Use aria-label when the label is shown elsewhere.
descriptionReactNode—Helper text below the field, linked with aria-describedby.
errorReactNode—Error message. Sets aria-invalid and replaces the description.
busybooleanfalseShows a spinner and sets aria-busy, for example while checking a value.
containerClassNamestring—Classes for the wrapper. className styles the input itself.
...propsComponentProps<"input">—Any native input attribute, including disabled, type, and value.

Accessibility

  • The label is a native <label> bound to the input with an id generated by useId.
  • Helper text and errors are linked with aria-describedby; errors also set aria-invalid.
  • Busy fields set aria-busy; the spinner is decorative and stops rotating with reduced motion.
  • Focus shows a ring that does not rely on color alone (border and ring).

Files

Installed into src/components/seamless, or components/seamless when the project has no src directory.

  • src/components/seamless/ui/input.tsxinput
  • npm packages: noneReact and Tailwind CSS v4 required

Source

The exact source the CLI installs, from /r/items/react/input/0.1.0.json.

ui/input.tsxShow
ui/input.tsx
"use client";

import { type ComponentProps, type ReactNode, useId } from "react";

export interface InputProps extends Omit<ComponentProps<"input">, "size"> {
  /** Visible label. Pass `aria-label` instead when the label is shown elsewhere. */
  label?: ReactNode;
  /** Helper text below the field. */
  description?: ReactNode;
  /** Error message. Marks the field invalid and replaces the description. */
  error?: ReactNode;
  /** Shows a spinner in the field, for example while a value is being checked. */
  busy?: boolean;
  /** Classes for the wrapping element. `className` styles the input itself. */
  containerClassName?: string;
}

export function Input({
  label,
  description,
  error,
  busy = false,
  id,
  className,
  containerClassName,
  disabled,
  "aria-describedby": describedBy,
  ...props
}: InputProps) {
  const generatedId = useId();
  const inputId = id ?? generatedId;
  const messageId = `${inputId}-message`;
  const invalid = error != null && error !== false;
  const message = invalid ? error : description;

  return (
    <div className={["grid gap-1.5", containerClassName].filter(Boolean).join(" ")}>
      {label ? (
        <label htmlFor={inputId} className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
          {label}
        </label>
      ) : null}
      <div className="relative">
        <input
          id={inputId}
          disabled={disabled}
          aria-invalid={invalid || undefined}
          aria-busy={busy || undefined}
          aria-describedby={[message ? messageId : null, describedBy].filter(Boolean).join(" ") || undefined}
          className={[
            "h-9 w-full rounded-lg border bg-white px-3 text-sm text-zinc-900 shadow-sm placeholder:text-zinc-400",
            "transition-[border-color,box-shadow] duration-150 motion-reduce:transition-none",
            "focus-visible:border-sky-500 focus-visible:ring-3 focus-visible:ring-sky-500/20 focus-visible:outline-none",
            "disabled:cursor-not-allowed disabled:bg-zinc-50 disabled:opacity-60",
            "dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:disabled:bg-zinc-900",
            invalid
              ? "border-red-400 focus-visible:border-red-500 focus-visible:ring-red-500/20 dark:border-red-500/60"
              : "border-zinc-300 dark:border-zinc-700",
            busy ? "pr-9" : "",
            className,
          ]
            .filter(Boolean)
            .join(" ")}
          {...props}
        />
        {busy ? (
          <svg
            aria-hidden="true"
            viewBox="0 0 16 16"
            fill="none"
            className="pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-zinc-400 motion-safe:animate-spin"
          >
            <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2" />
            <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
          </svg>
        ) : null}
      </div>
      {message ? (
        <p id={messageId} className={invalid ? "text-sm text-red-600 dark:text-red-400" : "text-sm text-zinc-500 dark:text-zinc-400"}>
          {message}
        </p>
      ) : null}
    </div>
  );
}