Input
A single-line text field on the card plane with the field boundary.
Vibra puts the field on the card plane with its --input boundary — the line weight that carries 3:1 against the page — in both modes, where shadcn's is transparent and turns translucent in the dark; it focuses with the kit's solid 2px accent outline instead of a translucent ring and goes to the muted plane when disabled.
Install
npx shadcn@latest add @vibra/inputNeeds the @vibra registry in your components.json — set it up once.
Examples
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export default function InputDemo() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="billing-email">Billing email</Label>
<Input id="billing-email" type="email" placeholder="finance@northwind.example" autoComplete="email" />
</div>
)
}With a description
A hint under the field, joined to it with aria-describedby so it is read after the label.
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
// The hint sits under the field and is joined to it with aria-describedby, so
// a screen reader reads it after the label instead of leaving it behind.
export default function InputDescription() {
return (
<Field className="w-full max-w-sm">
<FieldLabel htmlFor="input-description-reply-to">Reply-to address</FieldLabel>
<Input
id="input-description-reply-to"
type="email"
autoComplete="email"
defaultValue="accounts@northwind.example"
aria-describedby="input-description-reply-to-hint"
/>
<FieldDescription id="input-description-reply-to-hint">
Customers who answer an invoice email reach this inbox.
</FieldDescription>
</Field>
)
}Sized to the answer
Each field as wide as what it expects: a long legal name, a short postcode, a two-figure rate.
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
// A field as wide as the answer it expects says how long that answer is before
// anyone types: a legal name runs long, a postcode is short, a rate is two figures.
export default function InputWidths() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="input-widths-legal-name">Legal name</Label>
<Input id="input-widths-legal-name" autoComplete="organization" defaultValue="Northwind Analytics Ltd" />
</div>
<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="input-widths-postcode">Postcode</Label>
<Input id="input-widths-postcode" autoComplete="postal-code" defaultValue="D02 X285" className="w-28" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="input-widths-vat-rate">VAT rate</Label>
<InputGroup className="w-24">
<InputGroupInput id="input-widths-vat-rate" inputMode="decimal" defaultValue="23" className="tabular-nums" />
<InputGroupAddon align="inline-end">
<InputGroupText>%</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
</div>
</div>
)
}Time of day
A native time field in the kit's shape: the platform's own picker on a phone, the zone said beside it.
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// A native time field: the platform's own picker on a phone, typed hours and
// minutes on a desktop, and the time zone said beside it rather than guessed.
export default function InputTime() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-time-digest">Send the daily digest at</Label>
<Input
id="input-time-digest"
type="time"
step={900}
defaultValue="08:00"
aria-describedby="input-time-digest-zone"
className="w-32 tabular-nums"
/>
<p id="input-time-digest-zone" className="text-sm text-muted-foreground">
Dublin time, to the quarter hour.
</p>
</div>
)
}File
The browser's file picker in the field's shape. The rules come first, and a file that breaks one is refused in words.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const LIMIT = 1024 * 1024
// The browser's own file picker in the field's shape. The rules are said
// before the choice, and a file that breaks one is refused in words.
export default function InputFile() {
const [error, setError] = React.useState<string | null>(null)
return (
<Field className="w-full max-w-sm" data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="input-file-logo">Invoice logo</FieldLabel>
<Input
id="input-file-logo"
type="file"
accept="image/png,image/svg+xml"
aria-invalid={error ? true : undefined}
aria-describedby={error ? "input-file-logo-hint input-file-logo-error" : "input-file-logo-hint"}
onChange={(event) => {
const file = event.target.files?.[0]
setError(file && file.size > LIMIT ? `${file.name} is over 1 MB. Export it smaller and try again.` : null)
}}
/>
<FieldDescription id="input-file-logo-hint">PNG or SVG, up to 1 MB. Printed at the top of every invoice.</FieldDescription>
<FieldError id="input-file-logo-error">{error}</FieldError>
</Field>
)
}With an error
Checked when the reader leaves the field: aria-invalid, a message that says what to type, and both gone once it is fixed.
"use client"
import * as React from "react"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const VAT = /^[A-Z]{2}[0-9A-Z]{8,12}$/
const check = (value: string) =>
VAT.test(value.replace(/\s/g, "").toUpperCase()) ? null : "Enter the VAT number with its country code, like IE6388047V."
// Checked when the reader leaves the field, not on every key: the message says
// what to type, the field carries aria-invalid, and fixing it clears both at once.
export default function InputError() {
const [value, setValue] = React.useState("6388047V")
const [error, setError] = React.useState<string | null>(() => check("6388047V"))
return (
<Field className="w-full max-w-sm" data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="input-error-vat">VAT number</FieldLabel>
<Input
id="input-error-vat"
value={value}
autoComplete="off"
spellCheck={false}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "input-error-vat-message" : undefined}
onChange={(event) => {
setValue(event.target.value)
if (error && !check(event.target.value)) setError(null)
}}
onBlur={() => setError(check(value))}
className="font-mono uppercase"
/>
<FieldError id="input-error-vat-message">{error}</FieldError>
</Field>
)
}Rules as you type
The rules are the field's description, and each says in words whether it holds; the tick only repeats it.
"use client"
import * as React from "react"
import { CheckIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const RULES = [
{ text: "3 to 24 characters", ok: (v: string) => v.length >= 3 && v.length <= 24 },
{ text: "Lowercase letters, numbers and dashes", ok: (v: string) => /^[a-z0-9-]+$/.test(v) },
{ text: "Starts with a letter", ok: (v: string) => /^[a-z]/.test(v) },
]
// The rules are the field's description, so they are read on focus, and each
// one says in words whether it holds — the tick and the ring only repeat it.
export default function InputRules() {
const [handle, setHandle] = React.useState("nw")
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-rules-handle">Workspace handle</Label>
<Input
id="input-rules-handle"
value={handle}
autoComplete="off"
spellCheck={false}
aria-describedby="input-rules-handle-rules"
onChange={(event) => setHandle(event.target.value)}
className="font-mono"
/>
<ul id="input-rules-handle-rules" className="flex flex-col gap-1 text-sm">
{RULES.map((rule) => {
const ok = rule.ok(handle)
const Icon = ok ? CheckIcon : CircleIcon
return (
<li key={rule.text} className={cn("flex items-center gap-2", ok ? "text-foreground" : "text-muted-foreground")}>
<Icon aria-hidden="true" className={cn("size-3.5 shrink-0", ok ? "text-success" : "size-3")} />
<span className="sr-only">{ok ? "Done:" : "To do:"}</span>
{rule.text}
</li>
)
})}
</ul>
</div>
)
}Checked by the server
A spinner while it asks, one status line for the answer, and aria-invalid when the address is taken.
"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
import { Spinner } from "@/components/ui/spinner"
const TAKEN = new Set(["northwind", "finance", "northwind-ops"])
type Answer = "free" | "taken"
// The server decides, so the field says it is asking: a spinner where the tick
// will be, and one status line that goes from "Checking…" to the answer.
export default function InputChecking() {
const [slug, setSlug] = React.useState("nw-finance")
const [answers, setAnswers] = React.useState<Partial<Record<string, Answer>>>({ "nw-finance": "free" })
const [asking, setAsking] = React.useState<string | null>(null)
React.useEffect(() => {
if (!slug || answers[slug]) return
const ask = setTimeout(() => setAsking(slug), 250)
const answer = setTimeout(() => {
setAnswers((known) => ({ ...known, [slug]: TAKEN.has(slug) ? "taken" : "free" }))
setAsking(null)
}, 850)
return () => {
clearTimeout(ask)
clearTimeout(answer)
}
}, [slug, answers])
const current = answers[slug] ?? (asking === slug ? "checking" : null)
const message =
current === "checking" ? `Checking ${slug}…`
: current === "free" ? `${slug} is free.`
: current === "taken" ? `${slug} is taken. Try ${slug}-hq.`
: null
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-checking-slug">Workspace address</Label>
<InputGroup>
<InputGroupAddon>
<InputGroupText id="input-checking-slug-prefix">app.northwind.example/</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="input-checking-slug"
value={slug}
autoComplete="off"
spellCheck={false}
aria-invalid={current === "taken" ? true : undefined}
aria-describedby="input-checking-slug-prefix input-checking-slug-status"
onChange={(event) => setSlug(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
/>
<InputGroupAddon align="inline-end">
{current === "checking" ? <Spinner aria-hidden="true" role={undefined} aria-label={undefined} /> : null}
{current === "free" ? <CheckIcon aria-hidden="true" className="text-success" /> : null}
</InputGroupAddon>
</InputGroup>
<p
id="input-checking-slug-status"
role="status"
aria-live="polite"
className={current === "taken" ? "min-h-5 text-sm text-destructive" : "min-h-5 text-sm text-muted-foreground"}
>
{message}
</p>
</div>
)
}Character limit
Going over is allowed and said in words, not cut off; a screen reader hears the count when typing pauses.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { useDebouncedValue } from "@/hooks/use-debounced-value"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const LIMIT = 11
// Carriers cut a text sender name at 11 characters, so the count is part of the
// field's description. Going over is allowed and said in words — a hard
// maxLength would drop keys silently — and a screen reader hears the count once
// typing pauses, not on every key.
export default function InputCount() {
const [name, setName] = React.useState("Northwind")
const over = name.length - LIMIT
const count = over > 0 ? `${over} ${over === 1 ? "character" : "characters"} too many` : `${name.length} of ${LIMIT} characters`
const spoken = useDebouncedValue(count, 700)
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-count-sender">SMS sender name</Label>
<Input
id="input-count-sender"
value={name}
autoComplete="off"
aria-invalid={over > 0 || undefined}
aria-describedby="input-count-sender-count"
onChange={(event) => setName(event.target.value)}
/>
<p
id="input-count-sender-count"
className={cn("text-sm tabular-nums", over > 0 ? "text-destructive" : "text-muted-foreground")}
>
{count}
</p>
<p className="sr-only" aria-live="polite">
{spoken}
</p>
</div>
)
}Disabled, with a reason
Tab skips a disabled field, so the reason is text under it, joined to it for whoever lands there.
import { LockIcon } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
// A disabled field is skipped by the Tab key, so the reason is plain text under
// it — read in the page, and joined to the field for whoever lands on it.
export default function InputDisabled() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-disabled-email">Sign-in email</Label>
<Input
id="input-disabled-email"
type="email"
disabled
defaultValue="maren.kovac@northwind.example"
aria-describedby="input-disabled-email-reason"
/>
<p id="input-disabled-email-reason" className="flex items-start gap-1.5 text-sm text-muted-foreground">
<LockIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0" />
Set by your company's single sign-on. An owner can change it under Security.
</p>
</div>
)
}Read-only, with copy
Read-only rather than disabled: in the Tab order, at full contrast, selectable, and copied whole in one press.
import { CopyButton } from "@/components/ui/copy-button"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
const WORKSPACE_ID = "ws_7Hq2kL9xR4"
// Read-only, not disabled: the value stays in the Tab order, at full contrast,
// selectable — and the copy button takes all of it in one press.
export default function InputReadonly() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-readonly-workspace-id">Workspace ID</Label>
<InputGroup className="bg-surface">
<InputGroupInput
id="input-readonly-workspace-id"
readOnly
value={WORKSPACE_ID}
aria-describedby="input-readonly-workspace-id-hint"
className="font-mono"
/>
<InputGroupAddon align="inline-end">
<CopyButton value={WORKSPACE_ID} label="Copy workspace ID" size="icon-xs" />
</InputGroupAddon>
</InputGroup>
<p id="input-readonly-workspace-id-hint" className="text-sm text-muted-foreground">
Support asks for this when you open a ticket. It never changes.
</p>
</div>
)
}With an icon
An InputGroup addon before the text. The icon only hints; the label names the field, so the glyph is hidden.
import { GlobeIcon } from "lucide-react"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
// The icon only hints at the kind of answer; the label still names the field,
// so the glyph is hidden from assistive technology.
export default function InputIcon() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-icon-website">Company website</Label>
<InputGroup>
<InputGroupAddon>
<GlobeIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput id="input-icon-website" type="url" autoComplete="url" placeholder="https://blueharbor.example" />
</InputGroup>
</div>
)
}With a prefix and a suffix
The fixed parts of an address sit in the field as text and describe it, so the whole address is heard.
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
// The fixed parts of an address sit inside the field as text. They are part of
// the answer, so the field is described by them: a screen reader hears the
// whole address, not only the part that is typed.
export default function InputAddons() {
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-addons-help-centre">Help centre address</Label>
<InputGroup>
<InputGroupAddon>
<InputGroupText id="input-addons-help-centre-scheme">https://</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id="input-addons-help-centre"
defaultValue="help"
autoComplete="off"
spellCheck={false}
aria-describedby="input-addons-help-centre-scheme input-addons-help-centre-domain"
/>
<InputGroupAddon align="inline-end">
<InputGroupText id="input-addons-help-centre-domain">.northwind.example</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
)
}With a button
A button that acts on this field alone sits inside it. A wrong code is an error on the field; a right one, a status line.
"use client"
import * as React from "react"
import { FieldError } from "@/components/ui/field"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
const CODES: Record<string, string> = { WELCOME20: "20% off your next three invoices", ANNUAL2: "two months free on a yearly plan" }
// The button acts on this field alone, so it sits inside it. Enter applies the
// code too; a wrong code is an error on the field, a right one a status line.
export default function InputButton() {
const [code, setCode] = React.useState("WELCOME20")
const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null)
function apply(event: React.FormEvent) {
event.preventDefault()
const clean = code.trim().toUpperCase()
setResult(
CODES[clean]
? { ok: true, text: `${clean} applied: ${CODES[clean]}.` }
: { ok: false, text: `${clean || "That code"} doesn't exist or has expired.` }
)
}
const invalid = result && !result.ok
return (
<form noValidate onSubmit={apply} className="flex w-full max-w-sm flex-col gap-2">
<Label htmlFor="input-button-code">Promotion code</Label>
<InputGroup>
<InputGroupInput
id="input-button-code"
value={code}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? "input-button-code-error" : undefined}
onChange={(event) => {
setCode(event.target.value)
setResult(null)
}}
className="font-mono uppercase"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton type="submit" variant="secondary">
Apply
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<FieldError id="input-button-code-error">{invalid ? result.text : null}</FieldError>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
{result?.ok ? result.text : null}
</p>
</form>
)
}With a keyboard shortcut
N focuses the field from anywhere but another field. The keycap shows it and aria-keyshortcuts says it.
"use client"
import * as React from "react"
import { matchesHotkey } from "@/lib/hotkeys"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { Kbd } from "@/components/ui/kbd"
import { Label } from "@/components/ui/label"
// N jumps to the field from anywhere on the page but inside another field,
// where it is just a letter. The keycap shows the key; aria-keyshortcuts says
// it to assistive technology; Enter adds the task and keeps the focus here.
export default function InputShortcut() {
const field = React.useRef<HTMLInputElement>(null)
const [task, setTask] = React.useState("")
const [added, setAdded] = React.useState<string | null>(null)
React.useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!matchesHotkey(event, "n")) return
event.preventDefault()
field.current?.focus()
}
document.addEventListener("keydown", onKeyDown)
return () => document.removeEventListener("keydown", onKeyDown)
}, [])
return (
<form
className="flex w-full max-w-sm flex-col gap-2"
onSubmit={(event) => {
event.preventDefault()
if (!task.trim()) return
setAdded(`Added "${task.trim()}" to Today.`)
setTask("")
}}
>
<Label htmlFor="input-shortcut-task">New task</Label>
<InputGroup>
<InputGroupInput
ref={field}
id="input-shortcut-task"
value={task}
autoComplete="off"
placeholder="Chase the Blue Harbor renewal"
aria-keyshortcuts="N"
onChange={(event) => setTask(event.target.value)}
/>
<InputGroupAddon align="inline-end" aria-hidden="true">
<Kbd>N</Kbd>
</InputGroupAddon>
</InputGroup>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
{added}
</p>
</form>
)
}Formats when you leave it
Typed however it was copied, grouped in fours on blur — never while typing, where a jumping caret loses keys.
"use client"
import * as React from "react"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const tidy = (value: string) => value.replace(/[^0-9a-z]/gi, "").toUpperCase()
const group = (value: string) => tidy(value).replace(/(.{4})(?=.)/g, "$1 ")
// Typed however it was copied, grouped in fours when the reader moves on —
// never while typing, where a jumping caret loses keys. The length check waits
// for the same moment.
export default function InputFormat() {
const [iban, setIban] = React.useState("IE29 AIBK 9311 5212 3456 78")
const [error, setError] = React.useState<string | null>(null)
return (
<Field className="w-full max-w-sm" data-invalid={error ? true : undefined}>
<FieldLabel htmlFor="input-format-iban">Payout account (IBAN)</FieldLabel>
<Input
id="input-format-iban"
value={iban}
autoComplete="off"
spellCheck={false}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "input-format-iban-hint input-format-iban-error" : "input-format-iban-hint"}
onChange={(event) => setIban(event.target.value)}
onBlur={() => {
setIban(group(iban))
setError(tidy(iban).startsWith("IE") && tidy(iban).length !== 22 ? "An Irish IBAN has 22 characters. Check the last few." : null)
}}
className="font-mono"
/>
<FieldDescription id="input-format-iban-hint">Spaces are fine; we group it in fours when you leave the field.</FieldDescription>
<FieldError id="input-format-iban-error">{error}</FieldError>
</Field>
)
}A range
Two fields under one legend, each named for its end; a range that runs backwards is refused on both.
"use client"
import * as React from "react"
import { FieldError, FieldLegend, FieldSet } from "@/components/ui/field"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { Label } from "@/components/ui/label"
const amount = (value: string) => Number(value.replace(/[^0-9.]/g, ""))
// Two fields under one legend: the fieldset names the pair, each field names its
// end, and a range that runs backwards is refused on both ends at once.
export default function InputRange() {
const [range, setRange] = React.useState({ from: "500", to: "2,500" })
const backwards = range.from !== "" && range.to !== "" && amount(range.from) > amount(range.to)
const end = (key: "from" | "to", label: string) => (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<Label htmlFor={`input-range-${key}`} className="text-xs text-muted-foreground">
{label}
</Label>
<InputGroup>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={`input-range-${key}`}
inputMode="decimal"
value={range[key]}
aria-invalid={backwards || undefined}
aria-describedby={backwards ? "input-range-error" : undefined}
onChange={(event) => setRange((current) => ({ ...current, [key]: event.target.value }))}
className="tabular-nums"
/>
</InputGroup>
</div>
)
return (
<FieldSet className="w-full max-w-sm gap-2">
<FieldLegend variant="label">Invoice amount</FieldLegend>
<div className="flex items-end gap-2">
{end("from", "Minimum")}
<span aria-hidden="true" className="pb-1.5 text-muted-foreground">
–
</span>
{end("to", "Maximum")}
</div>
<FieldError id="input-range-error">{backwards ? "The minimum is more than the maximum." : null}</FieldError>
</FieldSet>
)
}In a sentence
A number inside the sentence it finishes, labelled by the whole sentence so it is heard the way it is read.
import { Input } from "@/components/ui/input"
// A number set inside the sentence it finishes. The field is labelled by the
// whole sentence — its own value included — so it is heard the way it is read:
// "Remind customers 3 days before a renewal".
export default function InputInline() {
return (
<p className="flex max-w-sm flex-wrap items-center gap-x-2 gap-y-1 text-sm">
<span id="input-inline-before">Remind customers</span>
<Input
id="input-inline-days"
type="number"
min={1}
max={30}
defaultValue={3}
aria-labelledby="input-inline-before input-inline-days input-inline-after"
className="w-16 text-center tabular-nums"
/>
<span id="input-inline-after">days before a renewal</span>
</p>
)
}In a table
Fields in rows, each named for its row and column, with a total that follows them.
"use client"
import * as React from "react"
import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const money = new Intl.NumberFormat("en-IE", { style: "currency", currency: "EUR" })
const LINES = [
{ item: "Scale plan seats", quantity: "12", price: "24.00" },
{ item: "Onboarding workshop", quantity: "1", price: "1200.00" },
]
// Fields in rows have no room for a label each, so each one is named for its
// row and column — "Quantity, Scale plan seats" — and the total follows them.
export default function InputTable() {
const [lines, setLines] = React.useState(LINES)
const total = lines.reduce((sum, line) => sum + Number(line.quantity) * Number(line.price), 0)
const edit = (index: number, key: "quantity" | "price", value: string) =>
setLines((current) => current.map((line, i) => (i === index ? { ...line, [key]: value } : line)))
return (
<Table className="max-w-md">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead className="w-16 text-end">
<abbr title="Quantity" className="no-underline">Qty</abbr>
</TableHead>
<TableHead className="w-28 text-end">Unit price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, index) => (
<TableRow key={line.item}>
<TableCell className="whitespace-normal">{line.item}</TableCell>
<TableCell>
<Input
aria-label={`Quantity, ${line.item}`}
inputMode="numeric"
value={line.quantity}
onChange={(event) => edit(index, "quantity", event.target.value)}
className="h-7 text-end tabular-nums"
/>
</TableCell>
<TableCell>
<Input
aria-label={`Unit price in euros, ${line.item}`}
inputMode="decimal"
value={line.price}
onChange={(event) => edit(index, "price", event.target.value)}
className="h-7 text-end tabular-nums"
/>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>Total</TableCell>
<TableCell className="text-end tabular-nums">
{Number.isFinite(total) ? money.format(total) : "—"}
</TableCell>
</TableRow>
</TableFooter>
</Table>
)
}Right to left
An Arabic form with the icon at the inline start. The email field reads left to right, lined up with the form's edge.
import { MailIcon } from "lucide-react"
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
// An Arabic form: labels, hints and the icon start on the right. An email
// address is Latin text that reads left to right, so its field says so, and its
// value lines up with the form's edge.
export default function InputRtl() {
return (
<FieldGroup dir="rtl" lang="ar" className="w-full max-w-sm gap-4">
<Field>
<FieldLabel htmlFor="input-rtl-company">اسم الشركة</FieldLabel>
<Input id="input-rtl-company" defaultValue="نورثويند للتحليلات" aria-describedby="input-rtl-company-hint" />
<FieldDescription id="input-rtl-company-hint">يظهر في أعلى كل فاتورة.</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="input-rtl-email">البريد الإلكتروني للفواتير</FieldLabel>
<InputGroup>
<InputGroupAddon>
<MailIcon aria-hidden="true" />
</InputGroupAddon>
<InputGroupInput
id="input-rtl-email"
type="email"
dir="ltr"
defaultValue="finance@northwind.example"
className="text-end"
/>
</InputGroup>
</Field>
</FieldGroup>
)
}Dependencies
Registry
Source
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-card px-2.5 py-1 text-base transition-colors focus-ring file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive md:text-sm",
className
)}
{...props}
/>
)
}
export { Input }