Skip to content

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
<Input
type="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.

Change the props and copy the code.

We never share it.

variant
color
size
shape
label
content
state
Generated code
TSX
<Input type="email" label="Email" helper="We never share it." />
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.

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.

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.

We never share 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>
);
}

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.

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.

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.

On a bare input a spinner would need a background-image, and that does not follow currentColor the way Button’s ::before does.

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.

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.

At least 8 characters.

Code
<Input type="password" label="Password" helper="At least 8 characters." />
<Input
type="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.

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.

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 label over aria-label.
  • for/id is explicit, never an implicit wrap, so the control keeps an id that form libraries and aria-describedby can address.
  • Windows high contrast. forced-colors: active redraws the border from CanvasText, otherwise ghost would have none at all, and draws the focus ring as a CanvasText outline because box-shadow is dropped. Disabled switches to GrayText, 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-invalid or :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

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

An error message that tells the user what to do next. It is an instruction, not a verdict.

Don't

“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.

Checkbox, Radio and Toggle share this label wrapper too.