Input

Text field

A recessed field with a real label above it, and a message that arrives. The field is a hole, and a hole is opaque, because you are reading against it.

Usage

import { TextField } from '@carabine/ui/text-field';

Used in URLs, so keep it short.

<TextField
  label="Project name"
  defaultValue="carabine"
  hint="Used in URLs, so keep it short."
  full
/>

The message occupies its space before it has anything to say. A hint that pushes the next field down when it appears is a form that moves under the pointer.

Anatomy

<label>              // a real label, for a real input — htmlFor, not aria-labelledby
<div>                //   the recess: opaque, inset shadows, no border
  {icon}             //     inside, on the left
  <input />          //     everything an input takes passes through
  {trailing}         //     inside, on the right
</div>
<p id="…-message">   //   the hint, or the error in its place</p>

Give the field an id and the message becomes id-message, wired with aria-describedby — which is what a form library needs to point at.

Examples

Hints and errors

An error replaces the hint rather than joining it, and turns the field with it. Two messages under one field is two things to read before you know which one is the problem.

<TextField
  label="Email"
  type="email"
  icon={<Mail />}
  value={value}
  onValueChange={setValue}
  error={bad ? 'That does not look like an address.' : undefined}
  hint="We only use it for deploy failures."
  full
/>

A floating label

float puts the label inside the field and lifts it for anything underneath — text, focus, or a placeholder. It is a different shape, not a different component.

.dev
<TextField
  label="Domain"
  float
  icon={<Globe />}
  trailing={<span className="text-[11px]">.dev</span>}
  full
/>

Customization

<TextField
  label="Project"
  size={48}         // height in pixels; the padding and the label follow it
  radius={14}
  full              // fills the width it is given
  theme="light"
  // and everything an <input> takes:
  name="project"
  required
  autoComplete="off"
  maxLength={40}
  inputMode="text"
/>

Styling reference

Tailwind utilities; there is no stylesheet for this component. The field is a hole: opaque, with the recess drawn as inset shadows rather than a border, because a hole does not cast a shadow outwards. It is the one surface in the library that is neither a tint nor an object — you read against it, so you cannot see through it.

prefers-reduced-motion drops the float and the message's growth to nothing. The label still moves; it just arrives.

API reference

PropType
classNamestringAdded to the component’s own classes, so yours wins
styleCSSPropertiesMerged after the component’s own inline styles, so yours wins

Colours are data and dimensions are props, and neither covers a margin, a font, or a class from your own system. That is what these are for. State is legible from CSS as well — data-state, data-disabled, data-side — so a rule can answer it without knowing a single class name of ours. See Theming.

PropTypeDefault
labelstringAbove the field
floatbooleanfalsePuts it inside instead, rising when there is text
valuestringControlled
defaultValuestring''
onValueChange(value: string) => voidAlongside the native onChange
hintReactNodeSaid under the field, always
errorReactNodeSaid instead, and the field turns with it
iconReactNodeInside, on the left. The label makes room
trailingReactNodeInside, on the right
sizenumber40Height in pixels; padding and the label follow it
radiusnumbersize × 0.28
fullbooleanfalseFills the width it is given
theme'dark' | 'light''dark'
disabledbooleanfalse

What it refuses

  • No clear button, no counter, no reveal

    trailing takes any of them. A field that shipped all three would ship two of them switched off in every single use.

  • No validation

    It shows an error; it does not decide there is one. Whatever you already use — a schema, a form library, a server round trip — owns that, and a field with its own opinion about email addresses is a field you fight.