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
| Prop | Type | Default | What it does |
|---|---|---|---|
id | string | none | Required. Links the label, hint and error to the select without client JavaScript. |
label | ReactNode | none | Visible label above the select. |
hint | ReactNode | none | Help text under the select, 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. |
children | ReactNode | none | Native option and optgroup elements. |
className | string | none | Added to the root wrapper, not the select. |
...rest | Omit<ComponentProps<"select">, "size"> | none | Every native select attribute and ref, applied to the select. |
Anatomy
| root | .eq-select | Wrapper that stacks label, box and messages. |
|---|---|---|
| label | .eq-select-label | |
| control | .eq-select-control | Positions the chevron over the select. |
| input | .eq-select-input | The native select with appearance: none. |
| chevron | .eq-select-chevron | Decorative; clicks pass through to the select. |
| hint | .eq-select-hint | |
| error | .eq-select-error |
Keyboard
| SpaceEnterAlt+Down | Opens the picker. Exact keys vary by browser and platform; all provided by the browser. |
|---|---|
| UpDown | Moves through options; on some platforms this changes the value without opening the picker. |
| A-Z | Jumps to the next option starting with that letter. |
| Escape | Closes 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);
}
}