Skip to content

Input

  • Names, email addresses, passwords, numbers
  • A search box, with an icon in the slot before the text
  • Any single line of free text in a form

Not for text that runs over several lines — use a Textarea — or a choice from known options — use a Select or a Combobox.

Choosing between entering text:

ComponentUse it forFor example
InputOne line of text: a name, an email, a searchEmail address, search box
OTP inputA code of fixed length, typed or pasted one character per boxSMS code, two-factor code, PIN
TextareaSeveral lines of free text that may wrapComment, description, message
ComboboxTyping to find and pick from a list of known optionsChoose a city, add existing tags
FieldNot a control: wraps any of these to tie a label, a hint and an error to itAny form control with validation

Input is a real <input>. Typing, selection, autofill, validation and form submission are all the platform’s, so there is no headless half and nothing to keep in sync. It works with react-hook-form’s register() as-is.

Enter a valid email address.
import { Input } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
export default function InputBasics() {
return (
<>
{/* The <label> names the <input>, which is where id lands — the box is only a frame. */}
<div style={field}>
<label htmlFor="input-name">Name</label>
<Input id="input-name" placeholder="Ada Lovelace" />
</div>
<div style={field}>
<label htmlFor="input-email">Email</label>
<Input
id="input-email"
type="email"
defaultValue="not-an-email"
aria-invalid
aria-describedby="input-email-error"
/>
<small id="input-email-error" style={{ color: 'var(--color-status-error)' }}>
Enter a valid email address.
</small>
</div>
<div style={field}>
<label htmlFor="input-disabled">Disabled</label>
<Input id="input-disabled" defaultValue="Read me, don't edit me" disabled />
</div>
</>
);
}
<Input placeholder="Type here…" />

The <input> sits inside a <div class="input"> that draws the border, and the box is always there, with or without slots. That gives one rule for where props go:

PropLands on
classNamethe box
startSlot, endSlotinside the box
everything else, including id, ref, renderthe <input>

So a <label htmlFor>, aria-describedby, register() and a focus ref all reach the control, and className is where you set width or margins.

startSlot and endSlot hold anything: an icon, a prefix or unit, a keyboard hint, or a button. The box sizes itself around them, so wide content like https:// fits without tuning.

⌘K
kg
import { useState } from 'react';
import { Input, Kbd } from '@arun-dev/ui';
const field = { display: 'grid', gap: 'var(--space-3xs)', maxInlineSize: '20rem' };
/** A slot's button is the consumer's own element — styled, labelled and focused by them. */
const slotButton = {
display: 'inline-flex',
padding: 'var(--space-3xs)',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'none',
color: 'inherit',
font: 'inherit',
cursor: 'pointer',
};
export default function InputSlots() {
const [visible, setVisible] = useState(false);
return (
<>
<div style={field}>
<label htmlFor="input-search">Search</label>
<Input
id="input-search"
type="search"
placeholder="Components…"
startSlot={<SearchIcon />}
endSlot={<Kbd>⌘K</Kbd>}
/>
</div>
<div style={field}>
<label htmlFor="input-weight">Weight</label>
<Input id="input-weight" type="number" inputMode="decimal" endSlot="kg" />
</div>
<div style={field}>
<label htmlFor="input-password">Password</label>
<Input
id="input-password"
type={visible ? 'text' : 'password'}
defaultValue="correct horse"
endSlot={
<button
type="button"
style={slotButton}
aria-pressed={visible}
aria-controls="input-password"
onClick={() => setVisible((v) => !v)}
>
{visible ? 'Hide' : 'Show'}
</button>
}
/>
</div>
</>
);
}
/** Decorative: the label already says "Search", so the icon is hidden from assistive tech. */
function SearchIcon() {
return (
<svg
aria-hidden="true"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}

Slot content is yours

The slot renders what you pass and adds nothing. Mark a decorative icon aria-hidden, since the label already names the field. Give a slot button type="button", its own accessible name and its own focus style. The box’s focus ring follows the text control only, so a focused slot button draws its own ring and doesn’t light up the whole field.

Clicking a decorative icon does not move focus into the input. Moving it would take JavaScript in @arun-dev/ui, which decision 3 rules out. Click targets are the text and the label.

All of them come from the native control, read with :has(). There is no data-* attribute to set:

StateSet it with
focusthe platform (:focus-visible)
invalidaria-invalid on the input
disableddisabled on the input

Point aria-describedby at the error text too, so it is read out with the field.

Input props
PropTypeDefaultDescription
startSlotReactNodeContent before the text, inside the box: an icon, prefix or button. Mark decorative icons aria-hidden.
endSlotReactNodeContent after the text, inside the box: a unit, hint, or clear or reveal button.
classNamestringGoes on the box that frames the input and its slots, not on the <input>. Every other prop, ref and render included, goes on the <input>.
renderReactElementElement or component to render instead of the default. Props, className, event handlers and ref are merged onto it.
refRef<HTMLElement>Ref to the rendered element. Merged with any ref on the render element.
…restHTMLAttributesAnything else is spread onto the rendered element, so id, aria-*, data-* and event handlers all reach the DOM.
  • For text-like types only: text, email, password, search, tel, url, number. Use Checkbox, Radio group or Switch for the others.
  • Restyle every input with the --input-* tokens, e.g. --input-height or --input-border-color-focus.