# TextInput (/docs/text-input)





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

```tsx
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.

<Callout type="info" title="TextInput or Input?">
  Use `TextInput` whenever the field has a label — which is nearly always. Reach
  for [`Input`](/docs/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.
</Callout>

## Playground [#playground]

<story.Playground />

### Full prop browser [#full-prop-browser]

<story.WithControl />

## Label, description & error [#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.

<Preview align="block" code="&#x22;<TextInput label=\&#x22;Login\&#x22; placeholder=\&#x22;norminette\&#x22; />\n\n<TextInput label=\&#x22;Login\&#x22; description=\&#x22;Your intra handle, not your email.\&#x22; placeholder=\&#x22;norminette\&#x22; />\n\n<TextInput label=\&#x22;Login\&#x22; required placeholder=\&#x22;norminette\&#x22; />\n\n<TextInput label=\&#x22;Login\&#x22; error=\&#x22;That login is already taken\&#x22; defaultValue=\&#x22;norminette\&#x22; />&#x22;">
  <div className="flex w-full max-w-[20rem] flex-col gap-5">
    <TextInput label="Login" placeholder="norminette" />

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

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

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

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

## Sizes [#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`.

<Preview align="block" code="&#x22;<TextInput size=\&#x22;xs\&#x22; label=\&#x22;xs\&#x22; placeholder=\&#x22;xs\&#x22; />\n<TextInput size=\&#x22;sm\&#x22; label=\&#x22;sm\&#x22; placeholder=\&#x22;sm\&#x22; />\n<TextInput size=\&#x22;md\&#x22; label=\&#x22;md\&#x22; placeholder=\&#x22;md\&#x22; />\n<TextInput size=\&#x22;lg\&#x22; label=\&#x22;lg\&#x22; placeholder=\&#x22;lg\&#x22; />\n<TextInput size=\&#x22;xl\&#x22; label=\&#x22;xl\&#x22; placeholder=\&#x22;xl\&#x22; />&#x22;">
  <div className="flex w-full max-w-[20rem] flex-col gap-4">
    <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" />
  </div>
</Preview>

## Variants [#variants]

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

<Preview align="block" code="&#x22;<TextInput variant=\&#x22;default\&#x22; label=\&#x22;Default\&#x22; placeholder=\&#x22;Default\&#x22; />\n<TextInput variant=\&#x22;unstyled\&#x22; label=\&#x22;Unstyled\&#x22; placeholder=\&#x22;Unstyled\&#x22; />&#x22;">
  <div className="flex w-full max-w-[20rem] flex-col gap-4">
    <TextInput variant="default" label="Default" placeholder="Default" />

    <TextInput variant="unstyled" label="Unstyled" placeholder="Unstyled" />
  </div>
</Preview>

## Slots [#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`](/docs/input#inputbase--custom-controls) you compose yourself.

<Preview align="block" code="`<TextInput label=&#x22;Email&#x22; startSlot={<Mail />} placeholder=&#x22;login@student.42.fr&#x22; />`">
  <div className="flex w-full max-w-[20rem] flex-col gap-4">
    <TextInput label="Email" startSlot="<Mail />" placeholder="login@student.42.fr" />
  </div>
</Preview>

## Search fields [#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.

<Preview align="block" code="&#x22;<TextInput\n  label=\&#x22;Search participants\&#x22;\n  classNames={{ label: 'sr-only' }}\n  startSlot={<Search />}\n  placeholder=\&#x22;Search participants…\&#x22;\n/>&#x22;">
  <div className="flex w-full max-w-[20rem] flex-col gap-4">
    <TextInput label="Search participants" classNames="{ label: 'sr-only' }" startSlot="<Search />" placeholder="Search participants…" />
  </div>
</Preview>

## States [#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.

<Preview align="block" code="&#x22;<TextInput label=\&#x22;Login\&#x22; invalid defaultValue=\&#x22;not-a-login\&#x22; />\n<TextInput label=\&#x22;Login\&#x22; disabled defaultValue=\&#x22;Locked value\&#x22; />\n<TextInput label=\&#x22;Login\&#x22; readOnly defaultValue=\&#x22;norminette\&#x22; />&#x22;">
  <div className="flex w-full max-w-[20rem] flex-col gap-5">
    <TextInput label="Login" defaultValue="not-a-login" />

    <TextInput label="Login" defaultValue="Locked value" />

    <TextInput label="Login" defaultValue="norminette" />
  </div>
</Preview>

## Forms [#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](/docs/forms) guide for the full contract.

```tsx
<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 [#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:

```tsx
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 [#api]

<TypeTable
  type="{
  label: {
    type: 'ReactNode',
    description: 'Accessible name, rendered as a `<label>` bound to the input.',
  },
  description: {
    type: 'ReactNode',
    description: 'Helper text between the label and the control.',
  },
  error: {
    type: 'ReactNode',
    description: 'Error message below the control; its presence sets the invalid (red) state.',
  },
  required: {
    type: 'boolean',
    default: 'false',
    description: 'Required flag + a red asterisk on the label.',
  },
  variant: {
    type: '&#x22;default&#x22; | &#x22;unstyled&#x22;',
    default: '&#x22;default&#x22;',
    description: 'Shell treatment: bordered or chrome-less.',
  },
  size: {
    type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;',
    default: '&#x22;md&#x22;',
    description: 'Height, padding, control font, field typography, and slot icon size.',
  },
  startSlot: {
    type: 'ReactNode',
    description: 'Decorative content at the inline-start of the control (`aria-hidden`).',
  },
  endSlot: {
    type: 'ReactNode',
    description: 'Decorative content at the inline-end of the control (`aria-hidden`).',
  },
  invalid: {
    type: 'boolean',
    default: 'false',
    description: 'Red border + ring; sets aria-invalid. OR\'d with `error`.',
  },
  disabled: {
    type: 'boolean',
    default: 'false',
    description: 'Dims the shell and disables the input element.',
  },
  readOnly: {
    type: 'boolean',
    default: 'false',
    description: 'Focusable and full-contrast, but the value cannot change.',
  },
  ref: {
    type: 'Ref<HTMLInputElement>',
    description: 'Lands on the underlying `<input>`, for focus-on-error.',
  },
  classNames: {
    type: '{ field?, label?, root?, startSlot?, endSlot?, input? }',
    description:
      'Per-part class overrides. `field` is the Field wrapper, `label` the label element (pass `&#x22;sr-only&#x22;` to hide it visually), `root` the shell, `input` the inner element.',
  },
  testId: {
    type: 'string',
    description: 'data-testid on the real `<input>` element, not the shell. See the Testing guide.',
  },
}"
/>

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

## Accessibility [#accessibility]

* `label` is wired to the input by [`Field`](/docs/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](#search-fields).
* `startSlot` / `endSlot` are `aria-hidden` decorations; anything interactive must be composed into an [`InputBase`](/docs/input#inputbase--custom-controls) instead.
* RTL: the shell is a plain flex row, so the slots and padding flip automatically under `dir="rtl"`.
