Input
SaveA 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
Installation
npx @seamlessui/cli add inputPre-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-runUsage
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
| Prop | Type | Default | Description |
|---|---|---|---|
| label | ReactNode | — | Visible label, linked to the input. Use aria-label when the label is shown elsewhere. |
| description | ReactNode | — | Helper text below the field, linked with aria-describedby. |
| error | ReactNode | — | Error message. Sets aria-invalid and replaces the description. |
| busy | boolean | false | Shows a spinner and sets aria-busy, for example while checking a value. |
| containerClassName | string | — | Classes for the wrapper. className styles the input itself. |
| ...props | ComponentProps<"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.tsxShowHide
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>
);
}