Input
A single-line text field for names, emails, numbers and search, always paired with a visible label.
We’ll send the invite here.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function InputDemo() {
return (
<Field className="w-72">
<FieldLabel htmlFor="input-demo-email">Work email</FieldLabel>
<Input
id="input-demo-email"
type="email"
placeholder="ada@analytical.engine"
/>
<FieldDescription>We’ll send the invite here.</FieldDescription>
</Field>
)
}Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/input.jsonUsage
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input id="email" type="email" placeholder="you@company.com" />
</Field>An input on its own has no visible name. In forms, always compose it with Field, which lays out the label, description and error message consistently.
Examples
Sizes
sm, default and lg share their heights with Button and Select — 32, 36 and 40px — so an input and its action always align on one row.
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
const sizes = [
{ size: "sm", label: "Small · 32px" },
{ size: "default", label: "Default · 36px" },
{ size: "lg", label: "Large · 40px" },
] as const
export default function InputSizes() {
return (
<div className="flex w-full max-w-md flex-col gap-4">
{sizes.map(({ size, label }) => (
<div key={size} className="flex items-center gap-2">
<Input size={size} placeholder={label} aria-label={label} />
<Button size={size} variant="outline">
Apply
</Button>
</div>
))}
</div>
)
}Types
prfct styles every native input type. Choose the right type and inputMode so mobile keyboards match the data, and set autoComplete so browsers and password managers can fill the field.
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function InputTypes() {
return (
<FieldGroup className="grid w-full max-w-lg gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="input-types-password">Password</FieldLabel>
<Input
id="input-types-password"
type="password"
defaultValue="correct-horse"
autoComplete="new-password"
/>
</Field>
<Field>
<FieldLabel htmlFor="input-types-amount">Amount</FieldLabel>
<Input
id="input-types-amount"
type="number"
inputMode="decimal"
min={0}
step={0.01}
defaultValue={249.0}
/>
</Field>
<Field>
<FieldLabel htmlFor="input-types-date">Start date</FieldLabel>
<Input id="input-types-date" type="date" defaultValue="2026-10-01" />
</Field>
<Field>
<FieldLabel htmlFor="input-types-url">Website</FieldLabel>
<Input id="input-types-url" type="url" placeholder="https://" />
</Field>
</FieldGroup>
)
}Invalid
Set aria-invalid on the input and data-invalid on its Field, and explain the problem with a FieldError linked through aria-describedby. The border turns red and the focus halo follows, but the message and its icon carry the meaning — never color alone.
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function InputInvalid() {
return (
<Field data-invalid className="w-72">
<FieldLabel htmlFor="input-invalid-handle">Username</FieldLabel>
<Input
id="input-invalid-handle"
defaultValue="admin"
aria-invalid
aria-describedby="input-invalid-handle-error"
/>
<FieldError id="input-invalid-handle-error">
“admin” is reserved. Try another username.
</FieldError>
</Field>
)
}Disabled and read-only
Use disabled when a value can't be changed right now; it is skipped by keyboard focus and left out of form submissions. Use readOnly when people need to see, select or copy a value they can't edit — it stays focusable and is submitted with the form.
Contact your admin to change plans.
Read-only values stay focusable and selectable.
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function InputStates() {
return (
<FieldGroup className="w-full max-w-sm">
<Field data-disabled>
<FieldLabel htmlFor="input-states-plan">Plan</FieldLabel>
<Input id="input-states-plan" defaultValue="Enterprise" disabled />
<FieldDescription>Contact your admin to change plans.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="input-states-id">Workspace ID</FieldLabel>
<Input
id="input-states-id"
defaultValue="ws_8f3k29d1"
readOnly
className="font-mono"
/>
<FieldDescription>
Read-only values stay focusable and selectable.
</FieldDescription>
</Field>
</FieldGroup>
)
}File
File inputs get a quiet, bordered selector button that matches the field. For drag-and-drop or multiple files with previews, build on top of this rather than replacing the native input — it keeps keyboard and assistive technology support for free.
PNG or JPG, up to 2 MB.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export default function InputFile() {
return (
<Field className="w-80">
<FieldLabel htmlFor="input-file-avatar">Profile photo</FieldLabel>
<Input
id="input-file-avatar"
type="file"
accept="image/png, image/jpeg"
/>
<FieldDescription>PNG or JPG, up to 2 MB.</FieldDescription>
</Field>
)
}With a button
Because heights are shared, an input and a button sit side by side without adjustment. Give the input an aria-label when there is no visible label.
"use client"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
export default function InputInlineForm() {
return (
<form
className="flex w-full max-w-sm items-center gap-2"
onSubmit={(event) => event.preventDefault()}
>
<Input
type="email"
name="email"
placeholder="you@company.com"
aria-label="Email address"
autoComplete="email"
required
/>
<Button type="submit">Subscribe</Button>
</form>
)
}Controlled
Pass value and onChange to derive UI from what people type — here, a character count and a live URL preview. Prefer uncontrolled inputs with defaultValue when you only need the value on submit.
prfct.dev/quarterly-planning18/32
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const LIMIT = 32
export default function InputControlled() {
const [name, setName] = React.useState("Quarterly planning")
// Accents become plain letters (é → e, ł → l) instead of disappearing.
const slug = name
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/ł/gi, "l")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
return (
<Field className="w-80">
<FieldLabel htmlFor="input-controlled-name">Project name</FieldLabel>
<Input
id="input-controlled-name"
value={name}
maxLength={LIMIT}
onChange={(event) => setName(event.target.value)}
/>
<FieldDescription className="flex justify-between gap-4">
<span className="truncate font-mono">prfct.dev/{slug || "…"}</span>
<span className="tabular-nums">
{name.length}/{LIMIT}
</span>
</FieldDescription>
</Field>
)
}Guidelines
When to use
- Short, free-form text on a single line: names, emails, URLs, amounts, search queries.
- Values with a native type the browser already understands:
email,number,date,url,password.
When not to use
- Paragraphs or multi-line text — use a Textarea.
- Choosing from a known list — use a Select or, for long lists, a Combobox.
- Fixed-length codes such as 2FA — use Input OTP.
- Icons, units or buttons inside the field — use an Input Group.
Labels, not placeholders
A placeholder disappears the moment someone starts typing, so it can't carry the field's name or instructions. Put the name in a visible label, instructions in a description, and use the placeholder — if at all — for an example of the expected format.
Width is a hint
The width of a field tells people how much to type. Size fields to their expected content — a postal code should look shorter than an address — instead of stretching every input to the container.
Validate at the right moment
Validate on submit, not on every keystroke and not on blur. After the first submit, re-validate as people type, so an error clears the moment it is fixed. See Forms.
Accessibility
Input renders a native <input> through Base UI, so it keeps every browser behavior — autofill, spellcheck, IME composition, form submission — and works inside Base UI's Field and Form when you need built-in validation.
- Name every input. Associate a visible label with
htmlFor/id, or give inputs without a visible label anaria-label. - Describe and explain. Link descriptions and errors with
aria-describedby; setaria-invalidwhile a value is invalid. - Identify purpose. Set
autoCompleteon personal data such asname,emailandtel(WCAG 1.3.5). - Contrast. The field border uses the
inputtoken, which meets 3:1 against the page for non-text contrast (WCAG 1.4.11). Placeholder text usesmuted-foregroundat 4.5:1 or more. - No zoom on focus. Text is 16px below the
mdbreakpoint so iOS Safari doesn't zoom the page when the field is focused, then 14px on larger screens.
| Key | Behavior |
|---|---|
Tab | Moves focus to the input. Focus shows as a brand-colored border with a soft halo. |
ShiftTab | Moves focus to the previous focusable element. |
Enter | Submits the enclosing form. |
API reference
Input
Renders an <input> element through the Base UI Input. Accepts every native input attribute and Base UI's own props, such as render and onValueChange; the props type is exported as InputProps. The native size attribute is replaced by prfct's size variant.
inputVariants
The class generator is exported for elements that should look like an input — for example a read-only value rendered as a <div>.
import { inputVariants } from "@/components/ui/input"
import { cn } from "@/lib/utils"
<div className={cn(inputVariants({ size: "sm" }), "flex items-center font-mono")}>
ws_8f3k29d1
</div>