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
Notify me about

Pick at least one.

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.

Send alerts to

You can change this later in settings.

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.

Delivery address
Quiet hours

No notifications between these times.

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

PropTypeDefaultWhat it does
idstringnoneRequired. Ties the hint and error to the group without client JavaScript.
legendReactNodenoneThe question the controls answer together.
hintReactNodenoneHelp text under the legend, read as the group's description.
errorReactNodenoneOne 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.
...restComponentProps<"fieldset">noneEvery native fieldset attribute, including name, disabled and ref.

Anatomy

root.eq-fieldsetThe fieldset; data-invalid while error is set.
legend.eq-fieldset-legend
hint.eq-fieldset-hint
body.eq-fieldset-bodyHolds 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;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index