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.
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.
That does not look like an address.
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.
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
| Prop | Type | |
|---|---|---|
| className | string | Added to the component’s own classes, so yours wins |
| style | CSSProperties | Merged 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.
| Prop | Type | Default | |
|---|---|---|---|
| label | string | — | Above the field |
| float | boolean | false | Puts it inside instead, rising when there is text |
| value | string | — | Controlled |
| defaultValue | string | '' | |
| onValueChange | (value: string) => void | — | Alongside the native onChange |
| hint | ReactNode | — | Said under the field, always |
| error | ReactNode | — | Said instead, and the field turns with it |
| icon | ReactNode | — | Inside, on the left. The label makes room |
| trailing | ReactNode | — | Inside, on the right |
| size | number | 40 | Height in pixels; padding and the label follow it |
| radius | number | size × 0.28 | |
| full | boolean | false | Fills the width it is given |
| theme | 'dark' | 'light' | 'dark' | |
| disabled | boolean | false |
What it refuses
No clear button, no counter, no reveal
trailingtakes 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.