Inputs
Fieldset
A group of related controls under one legend, with a hint and one error for the whole set.
- Built on
<fieldset><legend>aria-describedbyfieldset disabled- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.6 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Fieldset } from "@/components/equip/fieldset";
export default function Demo() {
return (
<Fieldset id="fieldset-demo" legend="Notify me about" hint="Pick at least one." style={{ width: 216 }}>
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: "var(--eq-text-m)" }}>
<input type="checkbox" style={{ accentColor: "var(--eq-solid)" }} name="notify" value="deploys" defaultChecked /> Failed deploys
</label>
<label style={{ display: "flex", gap: 8, alignItems: "center", fontSize: "var(--eq-text-m)" }}>
<input type="checkbox" style={{ accentColor: "var(--eq-solid)" }} name="notify" value="mentions" defaultChecked /> Mentions
</label>
</Fieldset>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<fieldset style="width:216px" id="fieldset-demo" class="eq-fieldset" aria-describedby="fieldset-demo-hint">
<legend class="eq-fieldset-legend">Notify me about</legend>
<p class="eq-fieldset-hint" id="fieldset-demo-hint">Pick at least one.</p>
<div class="eq-fieldset-body" data-layout="stack"><label style="display:flex;gap:8px;align-items:center;font-size:var(--eq-text-m)"><input type="checkbox" style="accent-color:var(--eq-solid)" name="notify" checked value="deploys"> Failed deploys</label><label style="display:flex;gap:8px;align-items:center;font-size:var(--eq-text-m)"><input type="checkbox" style="accent-color:var(--eq-solid)" name="notify" checked value="mentions"> Mentions</label></div>
</fieldset>Examples
Group error
Native required cannot say "at least one of these", so the check runs on submit and the error belongs to the group, not a single box.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Checkbox } from "@/components/equip/checkbox";
import { Fieldset } from "@/components/equip/fieldset";
export default function Example() {
const [error, setError] = useState("");
const [sent, setSent] = useState("");
return (
<form
noValidate
style={{ display: "grid", gap: 16, width: 260 }}
onSubmit={(event) => {
event.preventDefault();
const picked = new FormData(event.currentTarget).getAll("channel").map(String);
setError(picked.length === 0 ? "Choose at least one channel." : "");
setSent(picked.length === 0 ? "" : `Alerts go to ${picked.join(", ")}.`);
}}
>
<Fieldset id="fieldset-channels" legend="Send alerts to" hint="You can change this later in settings." error={error}>
<Checkbox name="channel" value="email" label="Email" />
<Checkbox name="channel" value="slack" label="Slack" />
<Checkbox name="channel" value="sms" label="Text message" />
</Fieldset>
<Button type="submit" variant="outline" style={{ justifySelf: "start" }}>
Save alerts
</Button>
{sent && <p style={{ margin: 0, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>{sent}</p>}
</form>
);
}Fields side by side
The legend names the set, so each field's own label can stay short.
Code
import { Field } from "@/components/equip/field";
import { Fieldset } from "@/components/equip/fieldset";
export default function Example() {
return (
<div style={{ display: "grid", gap: 20, width: "100%", maxWidth: 340 }}>
<Fieldset id="fieldset-address" legend="Delivery address" layout="grid">
<Field id="fieldset-address-city" label="City" autoComplete="address-level2" defaultValue="Leeds" />
<Field id="fieldset-address-postcode" label="Postcode" autoComplete="postal-code" defaultValue="LS1 4AP" />
</Fieldset>
<Fieldset id="fieldset-hours" legend="Quiet hours" hint="No notifications between these times." layout="grid" disabled>
<Field id="fieldset-hours-from" label="From" type="time" defaultValue="22:00" />
<Field id="fieldset-hours-to" label="To" type="time" defaultValue="07:00" />
</Fieldset>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:20px;width:100%;max-width:340px">
<fieldset id="fieldset-address" class="eq-fieldset">
<legend class="eq-fieldset-legend">Delivery address</legend>
<div class="eq-fieldset-body" data-layout="grid">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="fieldset-address-city">City</label>
<div class="eq-field-control">
<input type="text" autoComplete="address-level2" id="fieldset-address-city" class="eq-field-input" value="Leeds">
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="fieldset-address-postcode">Postcode</label>
<div class="eq-field-control">
<input type="text" autoComplete="postal-code" id="fieldset-address-postcode" class="eq-field-input" value="LS1 4AP">
</div>
</div>
</div>
</fieldset>
<fieldset disabled id="fieldset-hours" class="eq-fieldset" aria-describedby="fieldset-hours-hint">
<legend class="eq-fieldset-legend">Quiet hours</legend>
<p class="eq-fieldset-hint" id="fieldset-hours-hint">No notifications between these times.</p>
<div class="eq-fieldset-body" data-layout="grid">
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="fieldset-hours-from">From</label>
<div class="eq-field-control">
<input type="time" id="fieldset-hours-from" class="eq-field-input" value="22:00">
</div>
</div>
<div class="eq-field" data-size="m">
<label class="eq-field-label" for="fieldset-hours-to">To</label>
<div class="eq-field-control">
<input type="time" id="fieldset-hours-to" class="eq-field-input" value="07:00">
</div>
</div>
</div>
</fieldset>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Ties the hint and error to the group without client JavaScript. |
legend | ReactNode | none | The question the controls answer together. |
hint | ReactNode | none | Help text under the legend, read as the group's description. |
error | ReactNode | none | One error for the group. Adds a danger bar on the start edge and joins the description. |
layout | "stack" | "row" | "grid" | "stack" | A column of checks, a wrapping row, or fields side by side in even columns. |
...rest | ComponentProps<"fieldset"> | none | Every native fieldset attribute, including name, disabled and ref. |
Anatomy
| root | .eq-fieldset | The fieldset; data-invalid while error is set. |
|---|---|---|
| legend | .eq-fieldset-legend | |
| hint | .eq-fieldset-hint | |
| body | .eq-fieldset-body | Holds the controls; data-layout sets the arrangement. |
| error | .eq-fieldset-error |
Notes
- Screen readers announce the legend as the group's name when focus enters the first control.
- The hint and error are joined into the fieldset's aria-describedby, after any you pass.
- Use it for a set that is checked as a whole, such as at least one channel; give each control its own error when the problem is one field.
- disabled on the fieldset disables every control inside it natively.
- Renders on the server; Form's FormSection is the larger, titled section of a long form, this is the label-sized group within it.
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/fieldset.tsx65 lines
import type { ComponentProps, ReactNode } from "react";
import "./fieldset.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 FieldsetProps = ComponentProps<"fieldset"> & {
/** Ties the hint and error to the group. Required so the part can render on the server. */
id: string;
/** The question the controls answer together. Rendered as the legend. */
legend: ReactNode;
/** Help text under the legend, read as the group's description. */
hint?: ReactNode;
/** One error for the whole group, such as "Choose at least one channel." */
error?: ReactNode;
/** Lay the controls out in a column, a wrapping row or an even grid of fields. */
layout?: "stack" | "row" | "grid";
};
export function Fieldset({
id,
legend,
hint,
error,
layout = "stack",
className,
children,
"aria-describedby": describedBy,
...rest
}: FieldsetProps) {
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = present(error) ? `${id}-error` : undefined;
const described = [describedBy, hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<fieldset
{...rest}
id={id}
className={["eq-fieldset", className].filter(Boolean).join(" ")}
aria-describedby={described}
data-invalid={present(error) || undefined}
>
<legend className="eq-fieldset-legend">{legend}</legend>
{present(hint) && (
<p className="eq-fieldset-hint" id={hintId}>
{hint}
</p>
)}
<div className="eq-fieldset-body" data-layout={layout}>
{children}
</div>
{present(error) && (
<p className="eq-fieldset-error" id={errorId}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="8" cy="8" r="6" />
<path d="M8 5v3.5M8 11h.01" />
</svg>
{error}
</p>
)}
</fieldset>
);
}components/equip/fieldset.css83 lines
@layer equip {
.eq-fieldset {
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
color: var(--eq-ink);
font-family: var(--eq-font);
}
/* Floated, the legend leaves the border and sits in the content box, so the group's
padding (and its error bar) apply to it like everything else. */
.eq-fieldset-legend {
float: inline-start;
width: 100%;
margin: 0 0 var(--eq-space-1);
padding: 0;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-fieldset-legend + * {
clear: both;
}
.eq-fieldset-hint,
.eq-fieldset-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-fieldset-hint {
color: var(--eq-ink-2);
}
.eq-fieldset-body {
display: flex;
flex-direction: column;
gap: var(--eq-space-2);
min-width: 0;
padding-top: var(--eq-space-2);
}
.eq-fieldset-body[data-layout="row"] {
flex-direction: row;
flex-wrap: wrap;
column-gap: var(--eq-space-4);
}
/* Fields side by side, such as day, month and year, or city and postcode. */
.eq-fieldset-body[data-layout="grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 96px), 1fr));
gap: var(--eq-space-3);
}
/* The group's error marks it with a bar down the start edge, so the whole set reads as one problem. */
.eq-fieldset[data-invalid] {
padding-inline-start: var(--eq-space-3);
border-inline-start: 2px solid var(--eq-danger);
transition: padding var(--eq-dur-move) var(--eq-spring);
}
.eq-fieldset-error {
display: flex;
align-items: flex-start;
gap: var(--eq-space-1);
padding-top: var(--eq-space-2);
color: var(--eq-danger);
}
.eq-fieldset-error > svg {
flex: none;
width: 16px;
height: 16px;
margin-top: 1px;
}
.eq-fieldset:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}