Input
One component for every input type. It walks Button's size ladder, carries the same eight semantic roles, and can render its own label, helper, and error.
- Stable
- React 19
- Native <input>
- Form-library ready
- WCAG AA on all eight roles
We never share it.
We never share it.
Code
<Inputtype="email"label="Email"helper="We never share it."placeholder="you@example.com"/><div class="wayra-label" data-placement="top" data-size="md"><label class="wayra-label-text" for="usage-email">Email</label><input class="wayra-input" id="usage-email" type="email" placeholder="you@example.com" aria-describedby="usage-email-hint" /><p class="wayra-label-hint" id="usage-email-hint">We never share it.</p></div>label, helper and error are the only props that change the rendered structure. Pass
none of them and you get a bare <input>. Pass any one and the component wraps the field
in the label contract.
There is no type default. text is already the browser’s, so repeating it would add
nothing. Button is the opposite case: it pins type="button" because there the native
default is actively harmful.
Playground
Section titled “Playground”Change the props and copy the code.
We never share it.
Generated code
<Input type="email" label="Email" helper="We never share it." />Variants
Section titled “Variants”Code
<Input aria-label="outline" placeholder="outline" /><Input variant="filled" aria-label="filled" placeholder="filled" /><Input variant="soft" aria-label="soft" placeholder="soft" /><Input variant="ghost" aria-label="ghost" placeholder="ghost" /><input class="wayra-input" data-variant="outline" placeholder="outline" aria-label="outline" /><input class="wayra-input" data-variant="filled" placeholder="filled" aria-label="filled" /><input class="wayra-input" data-variant="soft" placeholder="soft" aria-label="soft" /><input class="wayra-input" data-variant="ghost" placeholder="ghost" aria-label="ghost" />| Variant | What it is |
|---|---|
outline |
default — a bordered field with its edge drawn in the role |
filled |
the role becomes the surface and -content the text, exactly as on Button |
soft |
neutral base-200 fill — the search field in a sidebar or toolbar |
ghost |
no resting border, revealed on hover and focus — editable table cells |
soft keeps its border. The fill alone does not mark the control: base-200 against
base-100 measures 1.11:1, well under the 3:1 WCAG asks of a control boundary. ghost
is the one deliberate exception, for places where the user already knows the field is
there. Hover leaves a disabled or read-only ghost borderless, in every member of the family
— ComboBox, InputPassword and the two pickers included, whose guard reads the input they wrap:
a border there would offer an edit the field will not take.
Colors
Section titled “Colors”The role draws the edge at full strength, at rest. An earlier version mixed 45% of the
role into gray, which passed contrast and still looked gray. Eight color values that
produce the same box read as a broken prop.
Code
<Input color="primary" aria-label="primary" placeholder="primary" /><Input color="secondary" aria-label="secondary" placeholder="secondary" /><Input color="accent" aria-label="accent" placeholder="accent" /><Input color="neutral" aria-label="neutral" placeholder="neutral" /><Input color="info" aria-label="info" placeholder="info" /><Input color="success" aria-label="success" placeholder="success" /><Input color="warning" aria-label="warning" placeholder="warning" /><Input color="error" aria-label="error" placeholder="error" /><input class="wayra-input" data-variant="outline" data-color="primary" placeholder="primary" aria-label="primary" /><input class="wayra-input" data-variant="outline" data-color="secondary" placeholder="secondary" aria-label="secondary" /><input class="wayra-input" data-variant="outline" data-color="accent" placeholder="accent" aria-label="accent" /><input class="wayra-input" data-variant="outline" data-color="neutral" placeholder="neutral" aria-label="neutral" /><input class="wayra-input" data-variant="outline" data-color="info" placeholder="info" aria-label="info" /><input class="wayra-input" data-variant="outline" data-color="success" placeholder="success" aria-label="success" /><input class="wayra-input" data-variant="outline" data-color="warning" placeholder="warning" aria-label="warning" /><input class="wayra-input" data-variant="outline" data-color="error" placeholder="error" aria-label="error" />On filled the role behaves exactly as it does on Button and Badge: surface plus
-content.
Code
<Input variant="filled" color="primary" aria-label="primary" placeholder="primary" /><Input variant="filled" color="secondary" aria-label="secondary" placeholder="secondary" /><Input variant="filled" color="accent" aria-label="accent" placeholder="accent" /><Input variant="filled" color="neutral" aria-label="neutral" placeholder="neutral" /><Input variant="filled" color="info" aria-label="info" placeholder="info" /><Input variant="filled" color="success" aria-label="success" placeholder="success" /><Input variant="filled" color="warning" aria-label="warning" placeholder="warning" /><Input variant="filled" color="error" aria-label="error" placeholder="error" /><input class="wayra-input" data-variant="filled" data-color="primary" placeholder="primary" aria-label="primary" /><input class="wayra-input" data-variant="filled" data-color="secondary" placeholder="secondary" aria-label="secondary" /><input class="wayra-input" data-variant="filled" data-color="accent" placeholder="accent" aria-label="accent" /><input class="wayra-input" data-variant="filled" data-color="neutral" placeholder="neutral" aria-label="neutral" /><input class="wayra-input" data-variant="filled" data-color="info" placeholder="info" aria-label="info" /><input class="wayra-input" data-variant="filled" data-color="success" placeholder="success" aria-label="success" /><input class="wayra-input" data-variant="filled" data-color="warning" placeholder="warning" aria-label="warning" /><input class="wayra-input" data-variant="filled" data-color="error" placeholder="error" aria-label="error" />Height and font size match Button step for step. Padding does not, and that is deliberate: a button centers a label, a field leads with text.
Code
<Input size="xs" aria-label="xs" placeholder="xs" /><Input size="sm" aria-label="sm" placeholder="sm" /><Input size="md" aria-label="md" placeholder="md" /><Input size="lg" aria-label="lg" placeholder="lg" /><Input size="xl" aria-label="xl" placeholder="xl" /><input class="wayra-input" data-size="xs" placeholder="xs" aria-label="xs" /><input class="wayra-input" data-size="sm" placeholder="sm" aria-label="sm" /><input class="wayra-input" data-size="md" placeholder="md" aria-label="md" /><input class="wayra-input" data-size="lg" placeholder="lg" aria-label="lg" /><input class="wayra-input" data-size="xl" placeholder="xl" aria-label="xl" />| Size | Height | Padding inline | Font size |
|---|---|---|---|
xs |
21.1px | 0.5rem | 11px |
sm |
28.2px | 0.625rem | 12px |
md |
35.2px | 0.75rem | 14px |
lg |
42.2px | 0.875rem | 18px |
xl |
49.3px | 1rem | 22px |
A form row lines up by construction, with no wrapper and no magic number:
Both at size=md: 35.2px tall, same font size, same radius token.
Code
<Input type="email" aria-label="Email" placeholder="you@example.com" /><Button>Subscribe</Button><input class="wayra-input" type="email" placeholder="you@example.com" aria-label="Subscribe email" /><button class="wayra-button" data-variant="filled">Subscribe</button>Code
<Input aria-label="rounded" placeholder="rounded" /><Input shape="rectangle" aria-label="rectangle" placeholder="rectangle" /><Input shape="pill" aria-label="pill" placeholder="pill" /><input class="wayra-input" data-shape="rounded" placeholder="rounded" aria-label="rounded" /><input class="wayra-input" data-shape="rectangle" placeholder="rectangle" aria-label="rectangle" /><input class="wayra-input" data-shape="pill" placeholder="pill" aria-label="pill" />There is no square or circle. A 1:1 text field has nowhere to put text. pill and
rectangle are absolute: they ignore the theme’s corner radius, the same rule Button’s
shapes follow.
There is no width prop and no data-width. All seven fields — Input, Textarea,
InputPassword, Select, ComboBox,
DatePicker and TimePicker — are
one declaration in field.css:
width: 100%;A field fills the box it is put in. Narrower means a narrower box — a grid cell, a flex
item, a max-width on a parent — never a variant on the field, so the layout owns that
axis and the component never argues with it. The label wrapper matches, which is why
wrapping a field in label never resizes it.
One component, every type. There is no InputText / InputNumber split: visually they
are the same control, and type already comes free from ComponentProps<"input">.
Code
<Input type="text" aria-label="text" placeholder="text" /><Input type="email" aria-label="email" placeholder="email" /><Input type="password" aria-label="password" placeholder="password" /><Input type="number" aria-label="number" placeholder="number" /><Input type="search" aria-label="search" placeholder="search" /><Input type="date" aria-label="date" placeholder="date" /><input class="wayra-input" type="text" placeholder="text" aria-label="text" /><input class="wayra-input" type="email" placeholder="email" aria-label="email" /><input class="wayra-input" type="password" placeholder="password" aria-label="password" /><input class="wayra-input" type="number" placeholder="number" aria-label="number" /><input class="wayra-input" type="search" placeholder="search" aria-label="search" /><input class="wayra-input" type="date" placeholder="date" aria-label="date" />The number spinners and the search field’s clear button are the browser’s own.
appearance: none does not remove them, and removing them by hand would cost more than it
buys.
Validation
Section titled “Validation”Two states, one look. The stylesheet keys on :user-invalid and
[aria-invalid="true"], and either one re-points the field’s accent at the error role.
Border and focus ring move together.
Type an invalid address in the first field and tab away.
Code
{/* the browser's own, after the user has interacted */}<Input type="email" required aria-label="Email" />
{/* what a form library sets, before the browser would */}<Input aria-invalid={Boolean(errors.email)} aria-label="Email" /><input class="wayra-input" type="email" required placeholder="type something wrong, then Tab" aria-label="Native validation" /><input class="wayra-input" aria-invalid="true" value="already marked" aria-label="Marked from JavaScript" />It keys on :user-invalid, not :invalid, so a required field is not red on page load.
It turns red once the user has interacted with it. The error color also shows at rest,
not only on focus: an error nobody can see is not an error message.
Nothing to configure. name, ref, value, defaultValue and onChange all come from
ComponentProps<"input">, so a native form, TanStack Form or Formik work with no adapter.
The integration point is error plus aria-invalid.
react-hook-form is built in. Inside Form — which provides the useForm instance you
own — an Input registers itself on name alone: no register spread, the validation
message arrives in the hint slot, and the field disables itself while the form submits.
An explicit error or disabled still wins, and noRhf turns the whole thing off for one
instance. Outside a Form, spread register("email") onto the field and it binds the same
way — the Input composes the handlers and merges the ref for you, so there is no prop to
pass. useRHFIntegration — the hook the Input calls internally — wires anything else by
hand.
const schema = z.object({ email: z.email("Enter a valid email address.") });
const form = useForm({ resolver: zodResolver(schema) });
<Form form={form} onSubmit={save}> <Input name="email" label="Email" /> <Button type="submit">Save</Button></Form>The schema library is yours to pick — zod here, but any resolver react-hook-form
accepts works the same. The Input never sees it.
Code
import { useState } from "react";import { Button, Input } from "wayra-ui";import { z } from "zod";
const emailSchema = z.email("Enter a valid email address.");
export function FormDemo() { const [email, setEmail] = useState(""); const [error, setError] = useState<string>(); const [submitted, setSubmitted] = useState<string>();
function submit(event: React.SubmitEvent<HTMLFormElement>) { event.preventDefault(); const result = emailSchema.safeParse(email); if (!result.success) { setError(result.error.issues[0]?.message); setSubmitted(undefined); return; } setError(undefined); setSubmitted(result.data); }
return ( // noValidate on purpose: this is the form-library path, where the error // comes from your own validation and reaches the field through `error`. <form className="hu-demo-col" onSubmit={submit} noValidate> <Input type="email" name="email" label="Email" placeholder="you@example.com" helper={error ? undefined : "We never share it."} error={error} value={email} onChange={(event) => setEmail(event.currentTarget.value)} /> <Button type="submit" width="block"> Subscribe </Button> {submitted && <p className="hu-readout">submitted: {submitted}</p>} </form> );}Read-only over disabled
Section titled “Read-only over disabled”Tab through them: the read-only field is reachable, the disabled one is not.
Code
<Input readOnly defaultValue="read-only@example.com" aria-label="Read only" /><Input disabled defaultValue="disabled@example.com" aria-label="Disabled" /><input class="wayra-input" readonly value="read-only@example.com" aria-label="Read only" /><input class="wayra-input" disabled value="disabled@example.com" aria-label="Disabled" />readOnly keeps the field in the tab order, keeps screen readers announcing it, and keeps
the text selectable and copyable. disabled gives up all three. Reach for disabled only
when the value itself is irrelevant.
disabled also fades the label and hint along with the field. readOnly leaves them at
full strength, because that field is still announced and still copyable.
There is no softDisabled here, unlike Button. readOnly is already the native way to say
“visible, focusable, not editable”, so an ARIA-only version would be a second way to say
the same thing.
What’s not included
Section titled “What’s not included”No icon slots
Section titled “No icon slots”A leading magnifier, a $ prefix, a password-reveal eye: anything that sits inside the
control’s box needs structure an <input> cannot grow, because it takes no children. That
would take a future Field / InputGroup component.
No fight with Chrome autofill
Section titled “No fight with Chrome autofill”Chrome’s :autofill paints its own background over the field. The usual inset box-shadow
hack would break the focus ring, so the field is left alone.
No loading state
Section titled “No loading state”On a bare input a spinner would need a background-image, and that does not follow
currentColor the way Button’s ::before does.
Label, helper, and error
Section titled “Label, helper, and error”A visible label beats aria-label on every count. It stays on screen, it is clickable, and
for/id still makes it the accessible name. The wrapper is an inline-grid that copies
the field’s own width, so a wrapped field sits where the bare input did.
Placement
Section titled “Placement”We never share it.
Code
import { useState } from "react";import { Button, Input } from "wayra-ui";
const PLACEMENTS = ["top", "left", "floating"] as const;
export function LabelPlacementDemo() { const [placement, setPlacement] = useState<(typeof PLACEMENTS)[number]>("top");
return ( <div className="hu-demo-col"> {/* biome-ignore lint/a11y/useSemanticElements: a fieldset carries form semantics and wants a legend; this is a toolbar of toggle buttons. */} <div className="hu-demo-row" role="group" aria-label="Label placement"> {PLACEMENTS.map((option) => ( <Button key={option} size="xs" variant="ghost" color="neutral" pressed={option === placement} onClick={() => setPlacement(option)} > {option} </Button> ))} </div>
<Input type="email" label="Email" labelPlacement={placement} helper="We never share it." // No placeholder on purpose: `floating` needs `:placeholder-shown` to // know the field is empty, and the component injects a single space // when you leave it out. /> </div> );}| Placement | What it is |
|---|---|
top |
default — the label above the field |
left |
the label beside it, vertically centered; the hint still sits under the field |
floating |
the label rests inside the field and lifts into a strip above it |
The floating label never straddles the border. The usual trick is a patch of background
behind the label, sitting on the border, and it breaks three ways here: filled and soft
are not base-100, ghost has no border to sit on, and Windows high contrast repaints the
patch anyway. Reserving a strip above the field costs one line of padding and works on all
four variants.
It floats when the field is focused, when it holds a value, and when the browser autofilled
it. Types that always show something (date, time, color, file and the rest) never
match :placeholder-shown, so they stay floated for good.
Helper and error
Section titled “Helper and error”At least 8 characters.
Must be at least 8 characters.
Code
<Input type="password" label="Password" helper="At least 8 characters." />
<Inputtype="password"label="Password"error="Must be at least 8 characters."/><div class="wayra-label" data-placement="top" data-size="md"><label class="wayra-label-text" for="hint-ok">Password</label><input class="wayra-input" id="hint-ok" type="password" aria-describedby="hint-ok-hint" /><p class="wayra-label-hint" id="hint-ok-hint">At least 8 characters.</p></div><div class="wayra-label" data-placement="top" data-size="md"><label class="wayra-label-text" for="hint-bad">Password</label><input class="wayra-input" id="hint-bad" type="password" aria-invalid="true" aria-describedby="hint-bad-hint" value="short" /><p class="wayra-label-hint" id="hint-bad-hint" role="alert">Must be at least 8 characters.</p></div>One slot, not two paragraphs: error replaces the helper it invalidates. It also renders
role="alert" so the message is announced, and sets aria-invalid="true" on the input. If
you passed aria-invalid yourself, yours wins, including an explicit false.
Both feed aria-describedby, and both merge with one of your own rather than replacing
it:
<Input label="Email" helper="We never share it." aria-describedby="privacy-note" />// → aria-describedby="privacy-note <generated hint id>"An error that resolves to an empty string does nothing: no hint slot, no invalid state.
That matters with form libraries, where errors.email?.message is "" as often as it is a
sentence.
unstyled reaches all three elements. Field, label, and hint drop their classes and keep
every data attribute, so one field can opt out even with styles.css loaded globally.
API reference
Section titled “API reference”InputProps extends ComponentProps<"input"> minus size, color, width and height,
which are redefined below because the HTML versions mean something else.
| Prop | Type | Default | Description |
|---|---|---|---|
variant |
"outline" | "filled" | "soft" | "ghost" |
"outline" |
See Variants. |
color |
WayraColor |
"primary" |
Draws the edge on outline/soft/ghost, the surface on filled. Consumed as data-color, never forwarded to the DOM. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Replaces the HTML size attribute, which counts characters. |
shape |
"rounded" | "rectangle" | "pill" |
"rounded" |
No square or circle — a 1:1 text field has nowhere to put text. |
label |
ReactNode |
— | Visible label. Wraps the field in the label contract. |
labelPlacement |
"top" | "left" | "floating" |
"top" |
Where the label sits. |
helper |
ReactNode |
— | Persistent hint, wired into aria-describedby. |
error |
ReactNode |
— | Takes the hint slot over helper, adds role="alert", sets aria-invalid. |
readOnly |
boolean |
false |
Native. Focusable, announced, copyable. |
disabled |
boolean |
false |
Native. Out of the tab order — prefer readOnly when the value matters. |
required |
boolean |
false |
Native. Drives :user-invalid once the user has interacted. |
unstyled |
boolean |
false |
Drops the design-system classes, keeps the data attributes. |
Everything else is the native attribute, typed: type, name, value, defaultValue,
onChange, ref, autoComplete, pattern, minLength and the rest.
Accessibility
Section titled “Accessibility”| Key | Behavior |
|---|---|
Tab / Shift+Tab |
Moves focus. Skips disabled, stops on readOnly. |
Enter |
Submits the enclosing form, including when the form has no submit button. |
| Text editing | Entirely native. appearance: none changes the painting, not the behavior. |
- Focus. The ring is drawn on
:focus, not:focus-visible. On a text field the ring is the only thing telling you where your typing will land, so it has to appear however the field was reached. Border and ring always match, and because the ring reads the field’s accent variable, an invalid field rings red with no second rule. - Naming. A placeholder is not a label. It disappears the moment the user types, and
not every screen reader treats it as the accessible name. Prefer
labeloveraria-label. for/idis explicit, never an implicit wrap, so the control keeps an id that form libraries andaria-describedbycan address.- Windows high contrast.
forced-colors: activeredraws the border fromCanvasText, otherwiseghostwould have none at all, and draws the focus ring as aCanvasTextoutline becausebox-shadowis dropped. Disabled switches toGrayText, on the label and hint too. - Reduced motion. The 140ms color transitions are dropped, and the floating label moves without animating.
- The hint follows the control. Its error color keys on the input’s own
aria-invalidor:user-invalid, so hand-written markup gets it from the field alone. - Contrast. Measured against the real surface, not a fixed white:
| Measurement | Value | Verdict |
|---|---|---|
Placeholder (base-content 63% → surface) |
5.17:1 light, 5.07:1 dark on soft, the tightest surface |
✓ AA with room |
The faint hairline border (base-300) |
1.27:1 light | ✗ fails the 3:1 rule |
| The shipped border (the role at full strength) | ≥ 4.5:1 in every theme that promises AA | ✓ (the default wayra misses on four roles) |
soft fill against the page |
1.11:1 | ✗ why it keeps one |
The placeholder does not dim on filled, and cannot. The themes that promise AA fit
their role and -content pairs close to the line, the tightest at 4.50:1, so mixing the
hint toward the background by any amount drops it under AA. So on that variant a
placeholder looks like typed text, which is the smaller of the two costs.
Do and don’t
Section titled “Do and don’t”Do
A visible label above the field. It survives typing, it is clickable, and it names the field for everyone.
Don't
A placeholder standing in for the label. It vanishes the moment the user types, which is exactly when they need to check what they are filling in.
Do
Add the part after the @, like ana@example.com.
An error message that tells the user what to do next. It is an instruction, not a verdict.
Don't
Invalid input.
“Invalid input” repeats what the red border already said. The user still has to guess what is wrong.
Do
readOnly when the value still matters. The field stays focusable, announced, and copyable.
Don't
disabled on the same field. It leaves the tab order and many screen readers skip it, so nobody can copy the value they came for.
Do
outline for a standalone form field. The resting border is what tells the user a control is there.
Don't
ghost for the same field. With no resting boundary it reads as text until you hover or focus it.