Fragiola UI
Fields

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/field
palettes (focus to see ring)
states

Helper 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

MemberIs
rowthe box — border, background, height, radius, focus ring
bodythe control's area inside the box: flex, no padding, no border
controlthe bare middle — transparent in every sense
addonsibling of body inside row: no border of its own, only a divider
insetchild 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.

On this page