|Kit

TextInput

A single-line text field with its label, description, and error built in — Input plus Field, the way every other control in the form family already works.

TextInput is the single-line text field you reach for by default. It's Input — the same shell, the same slots, the same states — carrying its own label, description, error, and required through Field, exactly like Textarea, Select, and the rest of the family.

import { TextInput } from '@42-central/ui-react/text-input';

<TextInput label="Login" placeholder="login@student.42.fr" />

Native <input> props (value, onChange, name, type, autoComplete, maxLength, ref, …) forward straight through, so react-hook-form's register() binds it with no wrapper. className styles the shell root and classNames overrides individual parts.

TextInput or Input?

Use TextInput whenever the field has a label — which is nearly always. Reach for Input when you're composing the shell yourself: inside a Field you already control, or as the search box of a larger widget. Omit every field prop and TextInput mounts no Field at all, so the two render identically.

Playground

Component

Loading preview

Props

Code

<Component
  label="Login"
  size="md"
  placeholder="login@student.42.fr"
/>

Full prop browser

Label, description & error

label names the control, description is helper text above it, and error renders below it. Passing error flips the field to the invalid (red) state on its own — no separate invalid needed. required adds the semantic flag and a red asterisk.

Your intra handle, not your email.
That login is already taken

Code

<TextInput label="Login" placeholder="norminette" />

<TextInput label="Login" description="Your intra handle, not your email." placeholder="norminette" />

<TextInput label="Login" required placeholder="norminette" />

<TextInput label="Login" error="That login is already taken" defaultValue="norminette" />

description and error are independent — both can show at once, and the control references each through aria-describedby / aria-errormessage.

Sizes

Five steps — xs through xl. The label, helper text, control font, and slot icons all scale together, so a TextInput lines up with an Input, Textarea, or Select at the same size.

Code

<TextInput size="xs" label="xs" placeholder="xs" />
<TextInput size="sm" label="sm" placeholder="sm" />
<TextInput size="md" label="md" placeholder="md" />
<TextInput size="lg" label="lg" placeholder="lg" />
<TextInput size="xl" label="xl" placeholder="xl" />

Variants

Two shells, shared with the rest of the family. default is bordered; unstyled strips the chrome but keeps the layout.

Code

<TextInput variant="default" label="Default" placeholder="Default" />
<TextInput variant="unstyled" label="Unstyled" placeholder="Unstyled" />

Slots

startSlot and endSlot are RTL-aware (start = left in LTR, right in RTL), and icons in them auto-size to the field's size. Both wrappers are aria-hidden, so keep them decorative — an interactive control belongs in an InputBase you compose yourself.

Code

<TextInput label="Email" startSlot={<Mail />} placeholder="login@student.42.fr" />

Search fields

A search box needs an accessible name, but rarely a visible label — and a placeholder is not a substitute, since it disappears the moment someone types. Pass a real label and hide it with classNames={{ label: 'sr-only' }}: screen readers still announce it, and nothing is added to the layout.

Code

<TextInput
  label="Search participants"
  classNames={{ label: 'sr-only' }}
  startSlot={<Search />}
  placeholder="Search participants…"
/>

States

invalid swaps the border and focus ring to red (and sets aria-invalid) without rendering a message — use it when the error text lives elsewhere, e.g. driven from a form library's fieldState.invalid. disabled dims the shell and blocks input; readOnly keeps full contrast and stays focusable but rejects edits.

Code

<TextInput label="Login" invalid defaultValue="not-a-login" />
<TextInput label="Login" disabled defaultValue="Locked value" />
<TextInput label="Login" readOnly defaultValue="norminette" />

Forms

TextInput is event-based, like Input and Textarea: onChange receives the DOM event, so react-hook-form's register() binds it directly and no Controller is needed. See the Forms guide for the full contract.

<TextInput
  label="Email"
  type="email"
  error={errors.email?.message}
  {...register('email', { required: 'Email is required' })}
/>

ref lands on the underlying <input>, so react-hook-form's focus-on-error works out of the box. invalid is independent of error, so a form library can flip only the red state when the message renders somewhere else.

Composing with Field

Omit every field prop and TextInput mounts no Field — it renders exactly what a bare Input renders, and still inherits an outer Field you compose yourself. This matters for layouts where one Field wraps several controls:

import { Field } from '@42-central/ui-react/field';
import { TextInput } from '@42-central/ui-react/text-input';

<Field label="Date range" error={errors.range?.message}>
  <div className="flex gap-2">
    <TextInput type="date" {...register('from')} />
    <TextInput type="date" {...register('to')} />
  </div>
</Field>

API

Prop

Type

TextInput forwards every other prop to the underlying <input> (value, defaultValue, onChange, name, type, autoComplete, maxLength, pattern, …). className styles the shell root.

Accessibility

  • label is wired to the input by Field — clicking it focuses the control, and screen readers announce it as the control's name.
  • description and error are associated through aria-describedby and aria-errormessage, so both are announced without being read as part of the name.
  • error sets aria-invalid on the input, so the error state is announced as well as shown.
  • Never use placeholder as the label: it vanishes on first keystroke and is not a reliable accessible name. For a field with no visible label, keep label and hide it with classNames={{ label: 'sr-only' }} — see Search fields.
  • startSlot / endSlot are aria-hidden decorations; anything interactive must be composed into an InputBase instead.
  • RTL: the shell is a plain flex row, so the slots and padding flip automatically under dir="rtl".

Changelog

v0.10.0
  • New TextInput: an Input wired to its Field's label, description and error state, with no Control wrapper

    #145

On this page