Skip to content

TextField

Takes single- or multi-line text input. The label, the description and the error message are one component.

tsx
import { TextField } from 'neba';

<TextField label="Email" value={email} onChange={(event) => setEmail(event.target.value)} />;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface. Even solid is not flooded with colour: a field holds user data
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. The same heights as Button, so a row's baseline holds
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. The surface is white, so it reaches the edge, the focus ring and the caret
densityshared'default' | 'compact''default'Padding only: never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth. A field is a well, not a surface that floats: rarely raised
labelReactNodeLabel above the control, wired to it by Base UI's Field
descriptionReactNodeHelper text below the control
errorReactNodeError message below the control. Its presence also turns the field invalid
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
multilinebooleanfalseRenders a textarea instead of an input. Every other axis stays identical
rowsnumber3Visible rows in multiline mode
resize'none' | 'vertical' | 'horizontal' | 'both''vertical'Which way the user may drag it. Horizontal breaks a form's column
startIconReactNodeContent before the control
endIconReactNodeContent after the control
loadingbooleanfalseSpinner in place of endIcon. Typing is deliberately still allowed
readOnlybooleanfalseRead-only. Selecting and copying still work
disabledbooleanfalseUnavailable
fullWidthbooleanfalseStretches to the width of the container
shortcutsNebaShortcuts<HTMLInputElement | HTMLTextAreaElement>Key combinations to act on, spelled the way Shortcut draws them: { 'Mod+Enter': send }. Mod is Command on a Mac and Control everywhere else. Bound to the control, so event.currentTarget is the input or the textarea. It runs before onKeyDown and does not replace it, and nothing is prevented on your behalf
onChangeChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>The native change event. Reach for `onValueChange` when only the value matters
classNamesNebaSlots<'label' | 'shell' | 'control' | 'description' | 'error'>Class names for the parts behind the root. The root itself is className, so there is no root key

Every native <input> attribute passes straight through. color and size are omitted because they collide with the ones above, and onChange is widened so the same handler types against a <textarea> in multiline mode.

Examples

variant

None of the three weights flood the sheet with colour. What a field holds is text the user typed, and the caret, the selection and the placeholder all have to stay legible on top of it. color shows up in the border, the focus ring and the caret instead.

size

The same heights as Button, so a field and a button in one row share a baseline. On a field the height is a floor rather than a fixed row: text set larger than the step — a cron expression, a code, a figure — grows the shell instead of being clipped by it.

multiline · rows · resize

multiline renders a <textarea> and leaves every other axis alone. rows={1} is exactly as tall as the single-line field. resize defaults to the vertical axis only: horizontal resizing breaks a form's column alignment.

startIcon · endIcon · loading

loading puts a spinner in the endIcon slot and marks the field busy, but typing is still allowed: a field is usually loading because of what was just typed into it.

error · invalid · disabled · readOnly

Give error a message and the field also turns invalid, re-pointing the whole field at the danger family. To mark it invalid without a message, pass invalid directly.

value and onChange

Identical to the native <input>.

shortcuts

shortcuts is a map from a key combination to what it does, written the way Shortcut draws it, so the key a form shows a reader and the key it binds are the same string.

tsx
<TextField
  label="Message"
  multiline
  shortcuts={{
    'Mod+Enter': (event) => {
      event.preventDefault();
      send();
    },
    Escape: clear
  }}
/>

Mod is Command on a Mac and Control everywhere else. The modifiers are matched exactly, so Enter and Mod+Enter are two entries that never both fire.

It is bound to the control, so event.currentTarget is the <input> or the <textarea> and event.currentTarget.value is what was typed. Nothing is prevented for you: a Mod+Enter that must not also insert a newline calls preventDefault itself. onKeyDown still sees every keystroke and runs after the map: neither prop replaces the other.

classNames

className lands on the root (the column holding the label, the shell and the two lines under it), so the <input> is reached through classNames instead. There is no root key; that is what className already is.

tsx
<TextField
  label="Email"
  className="w-80"
  classNames={{ label: 'uppercase tracking-wide', control: 'font-mono' }}
/>

The slots are label, shell, control, description and error. shell is the framed box wearing the border, the fill and the focus ring; control is the <input> or <textarea> inside it. See prop conventions for how a class name you pass resolves against the component's own.

Accessibility

  • label, description and error are connected to the control with id and aria-describedby.
  • There is no floating-label variant.
  • The focus ring belongs to the shell rather than the <input> inside it, so it traces the border.
  • Clicking the shell's own padding puts the caret in the field.

Released under the MIT License