Inputs
Field
A single-line text input with its label, hint and error. Reach for it for any typed value in a form.
- Built on
<input><label for>aria-describedby:user-invalid:has()- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.4 kB
- Dependencies
- React
Between 100 and 30000.
Try its options
Loading its options.
Code
import { Field } from "@/components/equip/field";
export default function Demo() {
return (
<div style={{ width: 200 }}>
<Field
id="field-demo-timeout"
label="Request timeout"
hint="Between 100 and 30000."
inputMode="numeric"
defaultValue="3000"
trail="ms"
/>
</div>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="width:200px">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-demo-timeout">Request timeout</label>
<div class="eq-field-control">
<input type="text" inputMode="numeric" id="field-demo-timeout" class="eq-field-input" aria-describedby="field-demo-timeout-hint" value="3000">
<span class="eq-field-trail">ms</span>
</div>
<p class="eq-field-hint" id="field-demo-timeout-hint">Between 100 and 30000.</p>
</div>
</div>Examples
Sizes
Heights match Button and Select at each size, so a field and its action line up in a row.
Code
import { Field } from "@/components/equip/field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<Field id="field-sizes-s" size="s" label="Branch" defaultValue="main" />
<Field id="field-sizes-m" size="m" label="Project name" defaultValue="Larder billing" />
<Field id="field-sizes-l" size="l" label="Display name" defaultValue="Ana Okafor" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:240px">
<div class="eq-field" data-size="s">
<label class="eq-field-label" for="field-sizes-s">Branch</label>
<div class="eq-field-control">
<input type="text" id="field-sizes-s" class="eq-field-input" value="main">
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-sizes-m">Project name</label>
<div class="eq-field-control">
<input type="text" id="field-sizes-m" class="eq-field-input" value="Larder billing">
</div>
</div>
<div class="eq-field" data-size="l">
<label class="eq-field-label" for="field-sizes-l">Display name</label>
<div class="eq-field-control">
<input type="text" id="field-sizes-l" class="eq-field-input" value="Ana Okafor">
</div>
</div>
</div>Lead and trail
Clicking the slots does not focus the input, so keep them short and put any meaning that matters in the label.
Code
import { Field } from "@/components/equip/field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<Field
id="field-slots-search"
type="search"
label="Search members"
placeholder="Name or email"
lead={
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" />
<path d="m10.5 10.5 3 3" />
</svg>
}
/>
<Field id="field-slots-price" label="Seat price" inputMode="decimal" defaultValue="12.00" lead="€" trail="per month" />
<Field id="field-slots-disk" label="Disk size" inputMode="numeric" defaultValue="80" trail="GB" />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:240px">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-slots-search">Search members</label>
<div class="eq-field-control">
<span class="eq-field-lead">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" />
<path d="m10.5 10.5 3 3" />
</svg>
</span>
<input type="search" placeholder="Name or email" id="field-slots-search" class="eq-field-input">
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-slots-price">Seat price</label>
<div class="eq-field-control">
<span class="eq-field-lead">€</span>
<input type="text" inputMode="decimal" id="field-slots-price" class="eq-field-input" value="12.00">
<span class="eq-field-trail">per month</span>
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-slots-disk">Disk size</label>
<div class="eq-field-control">
<input type="text" inputMode="numeric" id="field-slots-disk" class="eq-field-input" value="80">
<span class="eq-field-trail">GB</span>
</div>
</div>
</div>Errors and disabled
The second field has no error prop: it turns red through :user-invalid only after you edit it and leave it without a valid email.
Letters, numbers and dashes.
Spaces are not allowed.
Code
import { Field } from "@/components/equip/field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 12, width: 240 }}>
<Field
id="field-validation-handle"
label="Username"
defaultValue="ana okafor"
hint="Letters, numbers and dashes."
error="Spaces are not allowed."
/>
<Field id="field-validation-email" type="email" label="Billing email" placeholder="finance@company.com" required />
<Field id="field-validation-region" label="Region" defaultValue="eu-west-1" disabled />
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:240px">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-validation-handle">Username</label>
<div class="eq-field-control">
<input type="text" id="field-validation-handle" class="eq-field-input" aria-describedby="field-validation-handle-hint field-validation-handle-error" aria-invalid="true" value="ana okafor">
</div>
<p class="eq-field-hint" id="field-validation-handle-hint">Letters, numbers and dashes.</p>
<p class="eq-field-error" id="field-validation-handle-error">Spaces are not allowed.</p>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-validation-email">Billing email</label>
<div class="eq-field-control">
<input type="email" placeholder="finance@company.com" required id="field-validation-email" class="eq-field-input">
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-validation-region">Region</label>
<div class="eq-field-control">
<input type="text" disabled id="field-validation-region" class="eq-field-input" value="eu-west-1">
</div>
</div>
</div>With an inline action
The action lines up with the box, not the label, and stays there when an error appears underneath.
Codes are not case sensitive.
Enter a full email address, such as dana@larder.example.
Code
import { Button } from "@/components/equip/button";
import { Field } from "@/components/equip/field";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 320 }}>
<Field
id="field-action-coupon"
label="Discount code"
placeholder="WINTER25"
autoCapitalize="characters"
hint="Codes are not case sensitive."
action={<Button variant="outline">Apply</Button>}
/>
<Field
id="field-action-invite"
type="email"
label="Invite by email"
defaultValue="dana@larder"
error="Enter a full email address, such as dana@larder.example."
action={<Button>Send invite</Button>}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:16px;width:100%;max-width:320px">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-action-coupon">Discount code</label>
<div class="eq-field-row">
<div class="eq-field-control">
<input type="text" placeholder="WINTER25" autoCapitalize="characters" id="field-action-coupon" class="eq-field-input" aria-describedby="field-action-coupon-hint">
</div>
<div class="eq-field-action">
<button type="button" class="eq-button" data-variant="outline" data-size="m">
<span class="eq-button-label">Apply</span>
</button>
</div>
</div>
<p class="eq-field-hint" id="field-action-coupon-hint">Codes are not case sensitive.</p>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="field-action-invite">Invite by email</label>
<div class="eq-field-row">
<div class="eq-field-control">
<input type="email" id="field-action-invite" class="eq-field-input" aria-describedby="field-action-invite-error" aria-invalid="true" value="dana@larder">
</div>
<div class="eq-field-action">
<button type="button" class="eq-button" data-variant="solid" data-size="m">
<span class="eq-button-label">Send invite</span>
</button>
</div>
</div>
<p class="eq-field-error" id="field-action-invite-error">Enter a full email address, such as dana@larder.example.</p>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Links the label, hint and error to the input without client JavaScript. |
label | ReactNode | none | Visible label above the input. |
hint | ReactNode | none | Help text under the input, read as its description. |
error | ReactNode | none | Error text. Sets aria-invalid and adds the text to the description. |
size | "s" | "m" | "l" | "m" | Control height. Replaces the native size attribute. |
lead | ReactNode | none | Content inside the box before the text, such as an icon or currency sign. |
trail | ReactNode | none | Content inside the box after the text, such as a unit. |
trailInset | "text" | "button" | "text" | How far the trail sits from the box's end. button pulls it close, for a trail that is its own small button, such as show password or copy. |
action | ReactNode | none | An action attached beside the box, such as a Subscribe or Apply button. It sits on the input's row, lined up with the box rather than the label, and stays there when a hint or error appears. When the box would be narrower than about 11rem the action wraps under it. |
className | string | none | Added to the root wrapper, not the input. |
...rest | Omit<ComponentProps<"input">, "size"> | none | Every native input attribute and ref, applied to the input. type defaults to text. |
Anatomy
| root | .eq-field | Wrapper that stacks label, box and messages. |
|---|---|---|
| label | .eq-field-label | |
| control | .eq-field-control | The visible box. Carries the border, focus ring and slots. |
| input | .eq-field-input | The native input, borderless inside the box. |
| lead | .eq-field-lead | |
| trail | .eq-field-trail | Pulled close to the edge when trailInset is button. |
| row | .eq-field-row | Only with action: holds the box and the action on one line. |
| action | .eq-field-action | Only with action, after the box. |
| hint | .eq-field-hint | |
| error | .eq-field-error |
Notes
- Renders on the server with no client JavaScript; pass value and onChange from a client component as you would to a native input.
- The hint and error are joined into aria-describedby, after any aria-describedby you pass yourself.
- The border turns red when error is set, and also through :user-invalid once someone has edited a field that breaks a native constraint such as required or type="email".
- The native error bubble still appears on submit; error text is yours to render when you validate on the server.
- Lead and trail are decoration; if a unit matters to the meaning, put it in the label or hint as well.
- Size the action to the field, a medium Button beside a medium field, so their heights match.
- Give a submit action type="submit" and it submits the surrounding form.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/field.tsx100 lines
import type { ComponentProps, ReactNode } from "react";
import "./field.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
export type FieldProps = Omit<ComponentProps<"input">, "size"> & {
/** Ties the label, hint and error to the input. Required so the part can render on the server. */
id: string;
/** Visible label. Always rendered, never a placeholder stand-in. */
label: ReactNode;
/** Help text under the input, announced as its description. */
hint?: ReactNode;
/** Error text. When set, the input is marked invalid and the text is announced with it. */
error?: ReactNode;
size?: "s" | "m" | "l";
/** Content inside the box before the text, such as an icon or a currency sign. */
lead?: ReactNode;
/** Content inside the box after the text, such as a unit. */
trail?: ReactNode;
/**
* How far the trail sits from the box's end. "button" pulls it in close, for a trail that is
* its own small button (show password, clear, copy) rather than text.
*/
trailInset?: "text" | "button";
/**
* An action attached beside the box, such as a Subscribe or Apply button. It sits on the
* input's row, lined up with the box rather than the label, and stays there when a hint or
* error appears below.
*/
action?: ReactNode;
};
export function Field({
id,
label,
hint,
error,
size = "m",
lead,
trail,
trailInset = "text",
action,
className,
type = "text",
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
}: FieldProps) {
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = present(error) ? `${id}-error` : undefined;
const described = [describedBy, hintId, errorId].filter(Boolean).join(" ") || undefined;
const control = (
<div className="eq-field-control">
{present(lead) && <span className="eq-field-lead">{lead}</span>}
<input
type={type}
{...rest}
id={id}
className="eq-field-input"
aria-describedby={described}
aria-invalid={present(error) ? true : ariaInvalid}
/>
{present(trail) && <span className="eq-field-trail">{trail}</span>}
</div>
);
return (
<div
className={["eq-field", className].filter(Boolean).join(" ")}
data-size={size}
data-trail-inset={trailInset === "button" ? "button" : undefined}
>
<label className="eq-field-label" htmlFor={id}>
{label}
</label>
{present(action) ? (
<div className="eq-field-row">
{control}
<div className="eq-field-action">{action}</div>
</div>
) : (
control
)}
{present(hint) && (
<p className="eq-field-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-field-error" id={errorId}>
{error}
</p>
)}
</div>
);
}components/equip/field.css174 lines
@layer equip {
.eq-field {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-field-label {
width: fit-content;
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
color: var(--eq-ink);
}
.eq-field-control {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--eq-space-2);
width: 100%;
/* The inline padding lives on the input and slots, so the whole box is a click target. */
--_pad: var(--eq-space-3);
height: var(--eq-h-m);
padding: 0;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.2 var(--eq-font);
cursor: text;
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-field[data-size="s"] .eq-field-control {
--_pad: var(--eq-space-2);
height: var(--eq-h-s);
border-radius: var(--eq-radius-s);
font-size: var(--eq-text-s);
gap: var(--eq-space-1);
}
.eq-field[data-size="l"] .eq-field-control {
--_pad: var(--eq-space-4);
height: var(--eq-h-l);
font-size: var(--eq-text-l);
}
.eq-field-input {
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 100%;
margin: 0;
padding: 0 var(--_pad);
border: 0;
background: transparent;
appearance: none;
color: inherit;
font: inherit;
outline: none;
}
.eq-field-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-field-lead,
.eq-field-trail {
flex: none;
display: inline-flex;
align-items: center;
color: var(--eq-ink-3);
white-space: nowrap;
user-select: none;
}
.eq-field-lead {
padding-inline-start: var(--_pad);
}
.eq-field-trail {
padding-inline-end: var(--_pad);
}
/* Beside a slot the control's gap does the spacing. */
.eq-field-lead + .eq-field-input {
padding-inline-start: 0;
}
.eq-field-input:has(+ .eq-field-trail) {
padding-inline-end: 0;
}
/* A trail that is its own button sits close to the edge; its target does the spacing. */
.eq-field[data-trail-inset="button"] .eq-field-trail {
padding-inline-end: var(--eq-space-1);
}
/* With an action, the box and the action share one row; hint and error stay under it. */
/* The action wraps under the box once the box would be narrower than about 11rem. */
.eq-field-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-2);
min-width: 0;
}
.eq-field-row > .eq-field-control {
flex: 1 1 11rem;
min-width: 0;
}
.eq-field-action {
flex: none;
display: flex;
align-items: center;
gap: var(--eq-space-2);
}
.eq-field-lead > svg,
.eq-field-trail > svg {
width: 1.15em;
height: 1.15em;
}
@media (hover: hover) {
/* :where keeps this below the invalid rules, so a red border stays red on hover. */
.eq-field-control:hover:where(:not(:has(.eq-field-input:disabled))) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
/* The ring sits on the box, not the bare input, so the slots are inside it. */
.eq-field-control:has(.eq-field-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-field-control:has(.eq-field-input[aria-invalid="true"]) {
border-color: var(--eq-danger);
}
/* Native constraints (required, type, pattern) only after the person has interacted. */
.eq-field-control:has(.eq-field-input:user-invalid) {
border-color: var(--eq-danger);
}
.eq-field-control:has(.eq-field-input:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.eq-field-input:disabled {
cursor: not-allowed;
}
.eq-field-hint,
.eq-field-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-field-hint {
color: var(--eq-ink-2);
}
.eq-field-error {
color: var(--eq-danger);
}
}