Field
One way to write a field, and one input. The box lives on the row; the control is only its middle. The `field` family has ten members and zero variants.
npx shadcn@latest add @fragiola/fieldHelper text.
Show code
"use client";
import type { ReactNode } from "react";
import { Input } from "#/atoms/fields";
import { Field } from "#/ui/field";
// The palette class goes on the field element, not on a wrapping context.
// The floor is palette-surface; each Field.Root carries a palette-surface-*
// class — a neutral surface whose only difference from surface is a
// chromatic focus ring. The frame reads neutral; focusing shows the coloured
// ring. This is the Epic's exception: a field is a surface, not a coloured
// element, but its focus ring states the theme's colour.
//
// Axes shown, one per row, all aligned:
// palettes one field per surface-ring palette (default state)
// states default / invalid / disabled / required
const SURFACE_RING = [
"surface-blue",
"surface-purple",
"surface-green",
"surface-orange",
"surface-rose",
] as const;
export default function FieldDemo() {
return (
<div className="not-prose palette-surface flex flex-col gap-6 rounded-lg border border-palette-line bg-palette-base p-6">
{/* palettes — one field per surface-ring palette; focus to see
the ring colour. Frames read neutral in both themes. */}
<Row label="palettes (focus to see ring)">
{SURFACE_RING.map((palette) => (
<Field.Root
key={palette}
className={`palette-${palette} w-48`}
>
<Field.Label>{palette}</Field.Label>
<Field.Row>
<Field.Body>
<Input placeholder="Focus me" />
</Field.Body>
</Field.Row>
</Field.Root>
))}
</Row>
{/* states — default / invalid / disabled / required (palette
surface-blue). Invalid arrives by palette inheritance: Base UI
emits data-invalid on Field.Root, and danger.css registers
[data-invalid] as a palette-danger applier — the whole subtree
becomes danger with no extra class. */}
<Row label="states">
<Field.Root className="palette-surface-blue w-48">
<Field.Label>Default</Field.Label>
<Field.Row>
<Field.Body>
<Input placeholder="Default" />
</Field.Body>
</Field.Row>
<Field.Description>Helper text.</Field.Description>
</Field.Root>
<Field.Root invalid className="w-48">
<Field.Label>Invalid</Field.Label>
<Field.Row>
<Field.Body>
<Input defaultValue="admin" />
</Field.Body>
</Field.Row>
<Field.Error>This username is taken.</Field.Error>
</Field.Root>
<Field.Root disabled className="palette-surface-blue w-48">
<Field.Label>Disabled</Field.Label>
<Field.Row>
<Field.Body>
<Input defaultValue="Cannot edit" />
</Field.Body>
</Field.Row>
</Field.Root>
</Row>
</div>
);
}
// Row — a labelled, aligned row. The label is a <span>, not a heading.
function Row({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-mono text-palette-accent/85">
{label}
</span>
<div className="flex flex-wrap items-start gap-4">{children}</div>
</div>
);
}
Anatomy
| Member | Is |
|---|---|
row | the box — border, background, height, radius, focus ring |
body | the control's area inside the box: flex, no padding, no border |
control | the bare middle — transparent in every sense |
addon | sibling of body inside row: no border of its own, only a divider |
inset | child of body: inside the padding, no divider, no border |
The box is the row, not the body
An earlier version put the border on the body and gave the addon its own border. Joining two bordered boxes then required zeroing border and radius on the seam — that is where both of the project's !importants came from. With the border on the row, overflow-hidden clips the corners, no child declares a radius, and focusing the control highlights the whole field, addon included.
Invalid state by inheritance
data-invalid on the field root turns the whole subtree danger with no extra class on any child. Base UI emits data-invalid on Field.Root, and the themes register [data-invalid] as a palette-danger applier.
Addon sides
Four sides through data-side, in logical vocabulary: inline-start / inline-end use border-s / border-e and invert in RTL; block-start / block-end use basis-full + order-first/order-last and do not. Side is a data attribute, not a variant.
Clickable
The button surface — one component, three exports: `Button`, `Link` and `ExternalLink`. Every clickable affordance in the project composes against it: a dialog's close, an alert-dialog's action pair, a badge's dismiss, a field addon's button.
Input
One input component — `Input`, `Textarea` and `Numeric`. None of them carry a border, background, height, radius, focus ring, invalid state or theme. The box comes from the row around them.