Field
A labeled control with optional help text and errors.
Choose a unique username for your account.
States
Rest, filled, error, success, and disabled. Fill encodes availability; edge encodes interaction. Hover only strengthens the edge — never the fill. Focus, error, and success share 1px edge + 3px halo geometry. Disabled is the only state that changes fill.
Examples
Textarea
Select
Controls lose their own border inside a field — the card carries the frame.
Checkbox
Horizontal orientation puts the control beside the label. Wrap label and
description in FieldContent.
A short monthly email. No marketing.
Radio Group
Each option is its own horizontal field inside a FieldSet.
Choice card
Wrap the field in its own FieldLabel to make the whole card clickable.
Validation error
Mark the wrapper with data-invalid and the control with aria-invalid.
Help text and the error stack below the card — description first, then
the message.
In a dialog
The same Field card inside a dialog — rest and invalid.
Locked
data-locked uses the same muted fill as disabled, but the field can be
unlocked. Put the value in FieldLockedValue with onUnlock (default
label Change). The unlock control keeps cursor-pointer; the card
does not use not-allowed.
<Field data-locked>
<FieldTitle>Email</FieldTitle>
<FieldLockedValue onUnlock={() => goTo("email")}>
[email protected]
</FieldLockedValue>
</Field>Disabled
Disable the control (or set data-disabled on the Field). Same muted fill
as locked, but permanently non-interactive — cursor: not-allowed, and
any unlock control is hidden. Not an opacity fade.
Success
Mark the wrapper with data-success and put the message in
FieldDescription below the card (same slot as help; error replaces it
when both would show).
Field group
Stack related fields with FieldGroup. Each field keeps its own card.
We only use your email to send account notifications.
Experimental condensed
variant="experimental-condensed" tightens padding around the card and
the control metrics inside it.
Default
Choose a unique username for your account.
experimental-condensed
Choose a unique username for your account.
Playground
<Field className="max-w-sm"> <FieldLabel htmlFor="username">Username</FieldLabel> <Input id="username" placeholder="sophic" /> <FieldDescription>Choose a unique username for your account.</FieldDescription></Field>Usage
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
FieldLockedValue,
} from "@sophic/portal-ui/components/ui/field";<Field>
<FieldLabel htmlFor="username">Username</FieldLabel>
<Input id="username" />
<FieldDescription>Choose a unique username.</FieldDescription>
<FieldError errors={[fieldState.error]} />
</Field>Help text and errors written as direct children of Field render
below the card — not inside the input container. Nested helpers
(for example inside FieldContent on a checkbox row) stay with their
row.
The module also exports FieldGroup, FieldSet, FieldLegend,
FieldContent, FieldTitle, FieldLockedValue, and FieldSeparator for
composing larger form sections. This is the required wrapper for form
controls in app code.
See Field Composition for how controls align inside a Field and the live showcase.
Props
Every subcomponent also accepts its native element's attributes — <div>
for Field, <fieldset> for FieldSet, <legend> for FieldLegend, and so on.
Field
Prop
Type
Default
FieldLegend
Prop
Type
Default
FieldError
Renders nothing when there are no errors, a single message for one error, and a list for several.
Prop
Type
Default
FieldLockedValue
Use inside Field data-locked. Omit onUnlock (or put the Field in
data-disabled) when the value must stay permanent.
Prop
Type
Default
The rest
FieldGroup, FieldSet, FieldContent, FieldTitle, and
FieldDescription have no props of their own. FieldLabel forwards to
Label and takes htmlFor.
FieldSeparator renders its children as text inside the divider line.
Tokens
Field reads --field-* from the Base System (with fallbacks so apps
without those tokens keep the previous look). Nested controls switch to
--field-control-* inside the card.
Changelog
- 3 October 2026 — Dark rest fill is a 4% white lift over the parent; disabled sinks; locked keeps rest fill with muted value text. Full notes
- 3 October 2026 — Control edges use
--control-border, a step above layout--outer-border. Full notes - 3 October 2026 — Padding is
--spacing×--density; height is the line-box plus padding. Full notes - 3 October 2026 — Choice radios and checkboxes sit on the label line with a 12px gap. Full notes
- 3 October 2026 — Choice cards zero nested Field chrome so one surface owns fill, hover, and focus. Full notes
- 3 October 2026 — Dark checked choice cards use fill and the control, not a white ring. Full notes
- 1 October 2026 — Fill vs edge: hover is edge-only; focus / error /
success share 1px + 3px halo; disabled is the only permanent fill
change. Locked (
data-locked+FieldLockedValue) shares that fill but can unlock. Full notes - 14 September 2026 — Rest, hover, focus, filled, and error match the Base System input states (brand focus rim, muted label, error below). Full notes
- 18 August 2026 — Styles live in a CSS module; look and tokens unchanged. Full notes
- 29 July 2026 — Field hairline tracks Card’s
--card-ring(radius stays 16px). Full notes