Inputs

Select

A native select with its label, hint and error, for picking one value from a known list.

Built on
<select>appearance: none<label for>aria-describedby:user-invalid:has()
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.8 kB
Dependencies
React

Cannot be changed later.

Try its options

Loading its options.

Code
import { Select } from "@/components/equip/select";

export default function Demo() {
  return (
    <div style={{ width: 200 }}>
      <Select id="select-demo-region" label="Data region" defaultValue="eu-west-1" hint="Cannot be changed later.">
        <option value="eu-west-1">eu-west-1 (Ireland)</option>
        <option value="eu-central-1">eu-central-1 (Frankfurt)</option>
        <option value="us-east-1">us-east-1 (Virginia)</option>
        <option value="ap-south-1">ap-south-1 (Mumbai)</option>
      </Select>
    </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-select" data-size="m">
    <label class="eq-select-label" for="select-demo-region">Data region</label>
    <div class="eq-select-control">
      <select id="select-demo-region" class="eq-select-input" aria-describedby="select-demo-region-hint">
        <option value="eu-west-1" selected>eu-west-1 (Ireland)</option>
        <option value="eu-central-1">eu-central-1 (Frankfurt)</option>
        <option value="us-east-1">us-east-1 (Virginia)</option>
        <option value="ap-south-1">ap-south-1 (Mumbai)</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
    <p class="eq-select-hint" id="select-demo-region-hint">Cannot be changed later.</p>
  </div>
</div>

Examples

Sizes

Heights match Field and Button at each size.

Code
import { Select } from "@/components/equip/select";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, width: 240 }}>
      <Select id="select-sizes-s" size="s" label="Rows per page" defaultValue="50">
        <option value="25">25</option>
        <option value="50">50</option>
        <option value="100">100</option>
      </Select>
      <Select id="select-sizes-m" size="m" label="Role" defaultValue="editor">
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
        <option value="admin">Admin</option>
      </Select>
      <Select id="select-sizes-l" size="l" label="Time zone" defaultValue="Europe/Berlin">
        <option value="Europe/London">Europe/London</option>
        <option value="Europe/Berlin">Europe/Berlin</option>
        <option value="America/New_York">America/New_York</option>
      </Select>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:240px">
  <div class="eq-select" data-size="s">
    <label class="eq-select-label" for="select-sizes-s">Rows per page</label>
    <div class="eq-select-control">
      <select id="select-sizes-s" class="eq-select-input">
        <option value="25">25</option>
        <option value="50" selected>50</option>
        <option value="100">100</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
  <div class="eq-select" data-size="m">
    <label class="eq-select-label" for="select-sizes-m">Role</label>
    <div class="eq-select-control">
      <select id="select-sizes-m" class="eq-select-input">
        <option value="viewer">Viewer</option>
        <option value="editor" selected>Editor</option>
        <option value="admin">Admin</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
  <div class="eq-select" data-size="l">
    <label class="eq-select-label" for="select-sizes-l">Time zone</label>
    <div class="eq-select-control">
      <select id="select-sizes-l" class="eq-select-input">
        <option value="Europe/London">Europe/London</option>
        <option value="Europe/Berlin" selected>Europe/Berlin</option>
        <option value="America/New_York">America/New_York</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
</div>

Grouped options

Native optgroup labels are announced and drawn by the platform picker.

Code
import { Select } from "@/components/equip/select";

export default function Example() {
  return (
    <div style={{ width: 260 }}>
      <Select id="select-groups-runtime" label="Runtime" defaultValue="node-22">
        <optgroup label="Node.js">
          <option value="node-22">Node.js 22 LTS</option>
          <option value="node-20">Node.js 20 LTS</option>
        </optgroup>
        <optgroup label="Python">
          <option value="python-3.13">Python 3.13</option>
          <option value="python-3.12">Python 3.12</option>
        </optgroup>
        <option value="go-1.23" disabled>
          Go 1.23 (coming soon)
        </option>
      </Select>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="width:260px">
  <div class="eq-select" data-size="m">
    <label class="eq-select-label" for="select-groups-runtime">Runtime</label>
    <div class="eq-select-control">
      <select id="select-groups-runtime" class="eq-select-input">
        <optgroup label="Node.js">
          <option value="node-22" selected>Node.js 22 LTS</option>
          <option value="node-20">Node.js 20 LTS</option>
        </optgroup>
        <optgroup label="Python">
          <option value="python-3.13">Python 3.13</option>
          <option value="python-3.12">Python 3.12</option>
        </optgroup>
        <option value="go-1.23" disabled>Go 1.23 (coming soon)</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
</div>

Errors and disabled

The second select has no error prop: it is required with an empty first option, so :user-invalid marks it if its form is submitted while it is still empty.

Free plans cannot add more than 3 members.

Code
import { Select } from "@/components/equip/select";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 12, width: 260 }}>
      <Select id="select-validation-plan" label="Plan" defaultValue="free" error="Free plans cannot add more than 3 members.">
        <option value="free">Free</option>
        <option value="team">Team</option>
        <option value="business">Business</option>
      </Select>
      <Select id="select-validation-reason" label="Reason for leaving" defaultValue="" required>
        <option value="" disabled>
          Choose a reason
        </option>
        <option value="price">Too expensive</option>
        <option value="features">Missing features</option>
        <option value="switching">Moving to another tool</option>
      </Select>
      <Select id="select-validation-currency" label="Currency" defaultValue="EUR" disabled>
        <option value="EUR">EUR</option>
        <option value="USD">USD</option>
      </Select>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:12px;width:260px">
  <div class="eq-select" data-size="m">
    <label class="eq-select-label" for="select-validation-plan">Plan</label>
    <div class="eq-select-control">
      <select id="select-validation-plan" class="eq-select-input" aria-describedby="select-validation-plan-error" aria-invalid="true">
        <option value="free" selected>Free</option>
        <option value="team">Team</option>
        <option value="business">Business</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
    <p class="eq-select-error" id="select-validation-plan-error">Free plans cannot add more than 3 members.</p>
  </div>
  <div class="eq-select" data-size="m">
    <label class="eq-select-label" for="select-validation-reason">Reason for leaving</label>
    <div class="eq-select-control">
      <select required id="select-validation-reason" class="eq-select-input">
        <option value="" disabled selected>Choose a reason</option>
        <option value="price">Too expensive</option>
        <option value="features">Missing features</option>
        <option value="switching">Moving to another tool</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
  <div class="eq-select" data-size="m">
    <label class="eq-select-label" for="select-validation-currency">Currency</label>
    <div class="eq-select-control">
      <select disabled id="select-validation-currency" class="eq-select-input">
        <option value="EUR" selected>EUR</option>
        <option value="USD">USD</option>
      </select>
      <svg class="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
        <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
      </svg>
    </div>
  </div>
</div>

Props

PropTypeDefaultWhat it does
idstringnoneRequired. Links the label, hint and error to the select without client JavaScript.
labelReactNodenoneVisible label above the select.
hintReactNodenoneHelp text under the select, read as its description.
errorReactNodenoneError text. Sets aria-invalid and adds the text to the description.
size"s" | "m" | "l""m"Control height. Replaces the native size attribute.
childrenReactNodenoneNative option and optgroup elements.
classNamestringnoneAdded to the root wrapper, not the select.
...restOmit<ComponentProps<"select">, "size">noneEvery native select attribute and ref, applied to the select.

Anatomy

root.eq-selectWrapper that stacks label, box and messages.
label.eq-select-label
control.eq-select-controlPositions the chevron over the select.
input.eq-select-inputThe native select with appearance: none.
chevron.eq-select-chevronDecorative; clicks pass through to the select.
hint.eq-select-hint
error.eq-select-error

Keyboard

SpaceEnterAlt+DownOpens the picker. Exact keys vary by browser and platform; all provided by the browser.
UpDownMoves through options; on some platforms this changes the value without opening the picker.
A-ZJumps to the next option starting with that letter.
EscapeCloses the picker without changing the value.

Notes

  • Renders on the server with no client JavaScript; the picker is the platform's own, so it works with touch, screen readers and long lists.
  • The hint and error are joined into aria-describedby, after any aria-describedby you pass yourself.
  • For a required select, start with an empty disabled option such as Choose a region; :user-invalid then flags it if the form is submitted while it is still empty.
  • Only the closed box is styled; the open list is drawn by the operating system and ignores most CSS.
  • Pick Select for plain text options, long lists and touch, where the platform's picker serves best. Pick Dropdown when options need descriptions, icons or the finish's look in the open list.
  • Meant for a single choice; with multiple, the chevron and fixed height do not fit.

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/select.tsx68 lines
import type { ComponentProps, ReactNode } from "react";
import "./select.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 SelectProps = Omit<ComponentProps<"select">, "size"> & {
  /** Ties the label, hint and error to the select. Required so the part can render on the server. */
  id: string;
  /** Visible label above the select. */
  label: ReactNode;
  /** Help text under the select, announced as its description. */
  hint?: ReactNode;
  /** Error text. When set, the select is marked invalid and the text is announced with it. */
  error?: ReactNode;
  size?: "s" | "m" | "l";
};

export function Select({
  id,
  label,
  hint,
  error,
  size = "m",
  className,
  children,
  "aria-describedby": describedBy,
  "aria-invalid": ariaInvalid,
  ...rest
}: SelectProps) {
  const hintId = present(hint) ? `${id}-hint` : undefined;
  const errorId = present(error) ? `${id}-error` : undefined;
  const described = [describedBy, hintId, errorId].filter(Boolean).join(" ") || undefined;

  return (
    <div className={["eq-select", className].filter(Boolean).join(" ")} data-size={size}>
      <label className="eq-select-label" htmlFor={id}>
        {label}
      </label>
      <div className="eq-select-control">
        <select
          {...rest}
          id={id}
          className="eq-select-input"
          aria-describedby={described}
          aria-invalid={present(error) ? true : ariaInvalid}
        >
          {children}
        </select>
        <svg className="eq-select-chevron" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="m4.5 6.25 3.5 3.5 3.5-3.5" />
        </svg>
      </div>
      {present(hint) && (
        <p className="eq-select-hint" id={hintId}>
          {hint}
        </p>
      )}
      {present(error) && (
        <p className="eq-select-error" id={errorId}>
          {error}
        </p>
      )}
    </div>
  );
}
components/equip/select.css123 lines
@layer equip {
  .eq-select {
    display: flex;
    flex-direction: column;
    gap: var(--eq-space-1);
    min-width: 0;
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-select-label {
    width: fit-content;
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-select-control {
    position: relative;
    display: flex;
    width: 100%;
    color: var(--eq-ink);
  }

  .eq-select-input {
    appearance: none;
    box-sizing: border-box;
    flex: 1;
    min-width: 0;
    height: var(--eq-h-m);
    margin: 0;
    /* Room on the end for the chevron. */
    padding-inline: var(--eq-space-3) calc(var(--eq-space-3) + 16px + var(--eq-space-1));
    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);
    text-overflow: ellipsis;
    cursor: pointer;
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-select[data-size="s"] .eq-select-input {
    height: var(--eq-h-s);
    padding-inline: var(--eq-space-2) calc(var(--eq-space-2) + 16px + var(--eq-space-1));
    border-radius: var(--eq-radius-s);
    font-size: var(--eq-text-s);
  }

  .eq-select[data-size="l"] .eq-select-input {
    height: var(--eq-h-l);
    padding-inline: var(--eq-space-4) calc(var(--eq-space-4) + 16px + var(--eq-space-1));
    font-size: var(--eq-text-l);
  }

  .eq-select-chevron {
    position: absolute;
    top: 50%;
    inset-inline-end: var(--eq-space-3);
    width: 16px;
    height: 16px;
    translate: 0 -50%;
    color: var(--eq-ink-2);
    pointer-events: none;
  }

  .eq-select[data-size="s"] .eq-select-chevron {
    inset-inline-end: var(--eq-space-2);
  }

  .eq-select[data-size="l"] .eq-select-chevron {
    inset-inline-end: var(--eq-space-4);
  }

  /* Options are drawn by the platform picker; this only sets their colours where the engine allows it. */
  .eq-select-input option {
    background: var(--eq-raised);
    color: var(--eq-ink);
  }

  @media (hover: hover) {
    /* :where keeps this below the invalid rules, so a red border stays red on hover. */
    .eq-select-input:hover:where(:not(:disabled)) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  .eq-select-input:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-select-input[aria-invalid="true"] {
    border-color: var(--eq-danger);
  }

  /* A required select left on its empty option, once the person has interacted. */
  .eq-select-input:user-invalid {
    border-color: var(--eq-danger);
  }

  .eq-select-control:has(.eq-select-input:disabled) {
    opacity: 0.45;
  }

  .eq-select-input:disabled {
    cursor: not-allowed;
  }

  .eq-select-hint,
  .eq-select-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-select-hint {
    color: var(--eq-ink-2);
  }

  .eq-select-error {
    color: var(--eq-danger);
  }
}

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