Forms
Select
Custom dropdown with keyboard navigation
Preview
size
maxVisible
label
required
error
disabled
Code
import css from "./Select.module.css";
import { useEffect, useId, useRef, useState } from "react";
import { CaretRightIcon } from "@phosphor-icons/react/dist/csr/CaretRight";
import { CheckIcon } from "@phosphor-icons/react/dist/csr/Check";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface SelectProps {
label: string;
size: Size | ResponsiveSize;
maxVisible: number;
required?: boolean;
error?: boolean;
disabled?: boolean;
options: Option[];
value: string | number;
onChange: (value: string | number) => void;
}
// MUST match the CSS of .select_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 12;
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Select({
label,
size,
maxVisible,
required,
error,
disabled,
options,
value,
onChange,
}: SelectProps) {
const trigger = useRef<HTMLButtonElement>(null);
const select = useRef<HTMLDivElement>(null);
const optionsBox = useRef<HTMLDivElement>(null);
const id = useId();
const labelId = `${id}-label`;
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const [open, setOpen] = useState(false);
const [openUp, setOpenUp] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
// 0 is the empty value: the label stays in the field until something is chosen.
const active = value !== 0;
const selectedIndex = options.findIndex((option) => option.value === value);
const selected = options[selectedIndex];
const openList = () => {
if (trigger.current) {
const spaceBelow =
window.innerHeight - trigger.current.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
options.length * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
setOpenUp(spaceBelow < OFFSET + optionsHeight);
}
setOpen(true);
};
const closeList = () => {
setOpen(false);
setFocusedIndex(-1);
};
const onClick = () => {
if (disabled) return;
if (open) {
closeList();
return;
}
openList();
setFocusedIndex(selectedIndex);
};
const onKeyDown = (ev: React.KeyboardEvent) => {
if (disabled) return;
if (!open) {
if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
ev.preventDefault();
openList();
setFocusedIndex(selectedIndex >= 0 ? selectedIndex : 0);
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
setFocusedIndex((i) => (i + 1) % options.length);
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
setFocusedIndex((i) => (i - 1 + options.length) % options.length);
} else if (ev.key === "Home") {
ev.preventDefault();
setFocusedIndex(0);
} else if (ev.key === "End") {
ev.preventDefault();
setFocusedIndex(options.length - 1);
} else if (ev.key === "PageDown") {
ev.preventDefault();
setFocusedIndex((i) => Math.min(i + maxVisible, options.length - 1));
} else if (ev.key === "PageUp") {
ev.preventDefault();
setFocusedIndex((i) => Math.max(i - maxVisible, 0));
} else if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
if (focusedIndex >= 0) onSelect(options[focusedIndex]);
} else if (ev.key === "Escape") {
ev.preventDefault();
closeList();
trigger.current?.focus();
}
};
const onSelect = (option: Option) => {
if (disabled) return;
onChange(option.value);
closeList();
trigger.current?.focus();
};
useEffect(() => {
if (!open) return;
const onOutside = (ev: Event) => {
if (select.current && !select.current.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
return () => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
};
}, [open]);
useEffect(() => {
if (!open || focusedIndex < 0 || !optionsBox.current) return;
const option = optionsBox.current.children[focusedIndex] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
}, [focusedIndex, open]);
return (
<div
ref={select}
className={`
${css.select}
${getSizeClasses(size)}
${open ? css.open : ""}
${active ? css.active : ""}
${openUp ? css.open_up : ""}
${error ? css.error : ""}
${disabled ? css.disabled : ""}
`}
onKeyDown={onKeyDown}
>
<button
ref={trigger}
className={css.select_trigger}
type="button"
role="combobox"
disabled={disabled}
aria-labelledby={labelId}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-activedescendant={
focusedIndex >= 0 ? optionId(focusedIndex) : undefined
}
aria-required={required}
aria-invalid={error}
onClick={onClick}
>
<span>{selected?.name}</span>
<fieldset className={css.trigger_fieldset}>
<legend className={css.fieldset_legend}>{label}</legend>
</fieldset>
<span className={css.trigger_label} aria-hidden="true">
{/* aria-labelledby reads hidden text too: the id holds the label alone, so the * stays
out of the name. */}
<span id={labelId}>{label}</span>
{required && "*"}
</span>
<CaretRightIcon className={css.trigger_caret} aria-hidden="true" />
</button>
{/* A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. */}
<div
ref={optionsBox}
id={listboxId}
className={css.select_options}
style={{ "--max-visible": maxVisible } as React.CSSProperties}
role="listbox"
tabIndex={-1}
aria-labelledby={labelId}
>
{options.map((option, index) => (
<button
key={option.value}
id={optionId(index)}
className={`
${option.value === value ? css.selected : ""}
${index === focusedIndex ? css.focused : ""}
`}
type="button"
tabIndex={-1}
role="option"
aria-selected={option.value === value}
onClick={() => onSelect(option)}
>
{option.name}
{option.value === value && (
<CheckIcon className={css.option_check} aria-hidden="true" />
)}
</button>
))}
</div>
</div>
);
}
.select {
width: 100%;
position: relative;
-webkit-tap-highlight-color: transparent;
/* The field ladder: the height steps every tier, the font plateaus at --text-16.
The scale lives on the root: the popover is a sibling of the trigger and inherits the font from
here. */
&.xs {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
--select-height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
--select-height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
.select_trigger {
width: 100%;
height: var(--select-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-12);
background: none;
border: none;
border-radius: var(--radius-8);
outline: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
.trigger_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.trigger_label {
position: absolute;
top: 50%;
left: var(--space-12);
transform: translateY(-50%);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes
at rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.trigger_caret {
font-size: 20px;
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.select_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-12));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.selected {
color: var(--color-accent);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_check {
flex-shrink: 0;
font-size: 16px;
}
}
}
&.open {
.select_trigger {
.trigger_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.trigger_label {
top: 0;
left: var(--space-20);
transform: translateY(-70%) scale(0.875);
font-weight: var(--weight-600);
}
.trigger_caret {
transform: rotate(90deg);
}
}
.select_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
}
&.active {
.select_trigger {
.trigger_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.trigger_label {
top: 0;
left: var(--space-20);
transform: translateY(-70%) scale(0.875);
font-weight: var(--weight-600);
}
}
}
&.open_up {
.select_options {
top: auto;
bottom: calc(100% + var(--space-20));
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
/* After .open and .active so it outranks them. */
&:has(.select_trigger:focus-visible) .trigger_fieldset {
border-color: var(--color-accent);
border-width: 2px;
}
&.error {
/* Last on purpose: it ties with :focus-visible/.open/.active and wins by source order. */
.select_trigger .trigger_fieldset {
border-color: var(--color-error);
}
&.open .select_trigger .trigger_fieldset,
&.active .select_trigger .trigger_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
}
<script setup lang="ts">
import {
computed,
onWatcherCleanup,
ref,
useId,
useTemplateRef,
watch,
} from "vue";
import { PhCaretRight, PhCheck } from "@phosphor-icons/vue";
type Size = "xs" | "sm" | "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface Option {
name: string;
value: string | number;
}
interface SelectProps {
label: string;
size: Size | ResponsiveSize;
maxVisible: number;
required?: boolean;
error?: boolean;
disabled?: boolean;
options: Option[];
}
// MUST match the CSS of .select_options: row, gap, padding, border, half a row of peek, and the gap
// to the field.
const ROW = 40;
const GAP = 8;
const PADDING = 8;
const BORDER = 2;
const PEEK = ROW / 2;
const OFFSET = 12;
const { size, maxVisible, disabled, options } = defineProps<SelectProps>();
const model = defineModel<string | number>({ required: true });
const trigger = useTemplateRef<HTMLButtonElement>("trigger");
const select = useTemplateRef<HTMLDivElement>("select");
const optionsBox = useTemplateRef<HTMLDivElement>("optionsBox");
const id = useId();
const labelId = `${id}-label`;
const listboxId = `${id}-listbox`;
const optionId = (i: number) => `${id}-option-${i}`;
const open = ref(false);
const openUp = ref(false);
const focusedIndex = ref(-1);
// 0 is the empty value: the label stays in the field until something is chosen.
const active = computed(() => model.value !== 0);
const selectedIndex = computed(() =>
options.findIndex((option) => option.value === model.value),
);
const selected = computed(() => options[selectedIndex.value]);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const openList = () => {
if (trigger.value) {
const spaceBelow =
window.innerHeight - trigger.value.getBoundingClientRect().bottom;
// The list as the CSS draws it: natural height up to the cap.
const optionsHeight = Math.min(
options.length * (ROW + GAP) - GAP + PADDING * 2 + BORDER * 2,
PADDING + maxVisible * (ROW + GAP) + PEEK + BORDER * 2,
);
openUp.value = spaceBelow < OFFSET + optionsHeight;
}
open.value = true;
};
const closeList = () => {
open.value = false;
focusedIndex.value = -1;
};
const onClick = () => {
if (disabled) return;
if (open.value) {
closeList();
return;
}
openList();
focusedIndex.value = selectedIndex.value;
};
const onSelect = (option: Option) => {
if (disabled) return;
model.value = option.value;
closeList();
trigger.value?.focus();
};
const onKeyDown = (ev: KeyboardEvent) => {
if (disabled) return;
if (!open.value) {
if (ev.key === "Enter" || ev.key === " " || ev.key === "ArrowDown") {
ev.preventDefault();
openList();
focusedIndex.value = selectedIndex.value >= 0 ? selectedIndex.value : 0;
}
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
focusedIndex.value = (focusedIndex.value + 1) % options.length;
} else if (ev.key === "ArrowUp") {
ev.preventDefault();
focusedIndex.value =
(focusedIndex.value - 1 + options.length) % options.length;
} else if (ev.key === "Home") {
ev.preventDefault();
focusedIndex.value = 0;
} else if (ev.key === "End") {
ev.preventDefault();
focusedIndex.value = options.length - 1;
} else if (ev.key === "PageDown") {
ev.preventDefault();
focusedIndex.value = Math.min(
focusedIndex.value + maxVisible,
options.length - 1,
);
} else if (ev.key === "PageUp") {
ev.preventDefault();
focusedIndex.value = Math.max(focusedIndex.value - maxVisible, 0);
} else if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
if (focusedIndex.value >= 0) onSelect(options[focusedIndex.value]);
} else if (ev.key === "Escape") {
ev.preventDefault();
closeList();
trigger.value?.focus();
}
};
watch(open, () => {
if (!open.value) return;
const onOutside = (ev: Event) => {
if (select.value && !select.value.contains(ev.target as Node)) closeList();
};
document.addEventListener("click", onOutside);
document.addEventListener("focusin", onOutside);
onWatcherCleanup(() => {
document.removeEventListener("click", onOutside);
document.removeEventListener("focusin", onOutside);
});
});
// flush "post" so the option list is already rendered when we scroll to it.
watch(
[focusedIndex, open],
() => {
if (!open.value || focusedIndex.value < 0 || !optionsBox.value) return;
const option = optionsBox.value.children[focusedIndex.value] as HTMLElement;
option?.scrollIntoView({ block: "nearest" });
},
{ flush: "post" },
);
</script>
<template>
<div
ref="select"
class="select"
:class="[sizeClasses, { open, active, open_up: openUp, error, disabled }]"
@keydown="onKeyDown"
>
<button
ref="trigger"
class="select_trigger"
type="button"
role="combobox"
:disabled="disabled"
:aria-labelledby="labelId"
:aria-expanded="open"
aria-haspopup="listbox"
:aria-controls="listboxId"
:aria-activedescendant="
focusedIndex >= 0 ? optionId(focusedIndex) : undefined
"
:aria-required="required"
:aria-invalid="error"
@click="onClick"
>
<span>{{ selected?.name }}</span>
<fieldset class="trigger_fieldset">
<legend class="fieldset_legend">{{ label }}</legend>
</fieldset>
<span class="trigger_label" aria-hidden="true">
<!-- aria-labelledby reads hidden text too: the id holds the label alone, so the * stays
out of the name. -->
<span :id="labelId">{{ label }}</span><template v-if="required">*</template>
</span>
<PhCaretRight class="trigger_caret" aria-hidden="true" />
</button>
<!-- A scroller without focusable children is a Tab stop in Chrome; with aria-activedescendant
the focus never enters the list. -->
<div
ref="optionsBox"
:id="listboxId"
class="select_options"
:style="{ '--max-visible': maxVisible }"
role="listbox"
:tabindex="-1"
:aria-labelledby="labelId"
>
<button
v-for="(option, index) in options"
:key="option.value"
:id="optionId(index)"
:class="{
selected: option.value === model,
focused: index === focusedIndex,
}"
type="button"
:tabindex="-1"
role="option"
:aria-selected="option.value === model"
@click="onSelect(option)"
>
{{ option.name }}
<PhCheck v-if="option.value === model" class="option_check" aria-hidden="true" />
</button>
</div>
</div>
</template>
<style scoped>
.select {
width: 100%;
position: relative;
-webkit-tap-highlight-color: transparent;
/* The field ladder: the height steps every tier, the font plateaus at --text-16.
The scale lives on the root: the popover is a sibling of the trigger and inherits the font from
here. */
&.xs {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg {
--select-height: var(--size-48);
font-size: var(--text-16);
}
&.xs_base {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_base {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_base {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_base {
--select-height: var(--size-48);
font-size: var(--text-16);
}
@media (min-width: 480px) {
&.xs_mobile {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_mobile {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_mobile {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_mobile {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_tablet {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_tablet {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_tablet {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_desktop {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_desktop {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_desktop {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--select-height: var(--size-36);
font-size: var(--text-14);
}
&.sm_wide {
--select-height: var(--size-40);
font-size: var(--text-14);
}
&.md_wide {
--select-height: var(--size-44);
font-size: var(--text-16);
}
&.lg_wide {
--select-height: var(--size-48);
font-size: var(--text-16);
}
}
.select_trigger {
width: 100%;
height: var(--select-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-12);
background: none;
border: none;
border-radius: var(--radius-8);
outline: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
.trigger_fieldset {
position: absolute;
inset: 0;
border: var(--border-surface) solid var(--color-text);
border-radius: var(--radius-8);
padding: 0;
margin: 0;
pointer-events: none;
.fieldset_legend {
width: 0;
height: 0;
overflow: hidden;
padding: 0;
margin-left: var(--space-8);
/* The notch mirrors the floated label: 0.875em (inherit × its scale) and
weight 600, so it fits at every size. */
font-size: 0.875em;
font-weight: var(--weight-600);
visibility: hidden;
transition: width 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.trigger_label {
position: absolute;
top: 50%;
left: var(--space-12);
transform: translateY(-50%);
transform-origin: left center;
font-family: var(--font-body);
font-size: inherit;
font-weight: var(--weight-500);
color: var(--color-text);
pointer-events: none;
/* The float shrinks with transform, not font-size: smooth, and instant when the size changes
at rest. */
transition:
top 0.2s ease,
left 0.2s ease,
transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
.trigger_caret {
font-size: 20px;
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
}
.select_options {
/* maxVisible rows and half of the next; a max-height, so the half only shows when there is
more. */
max-height: calc(
var(--space-8) + var(--max-visible) * (var(--size-40) + var(--space-8)) +
var(--size-40) / 2 + var(--border-interactive) * 2
);
display: none;
position: absolute;
top: calc(100% + var(--space-12));
width: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 25%, transparent)
transparent;
background: var(--color-surface-opaque);
border: var(--border-interactive) solid var(--color-surface-opaque);
border-radius: var(--radius-8);
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 30%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 30%, transparent)
);
z-index: 1;
padding: var(--space-8);
scroll-padding: var(--space-8);
button {
width: 100%;
/* Flex items in a capped column shrink to their content, and the 40px row
the max-height assumes is gone. */
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-8);
padding: var(--space-8) var(--space-12);
background: none;
border: none;
cursor: pointer;
font-family: var(--font-body);
font-size: inherit;
height: var(--size-40);
font-weight: var(--weight-500);
color: var(--color-text);
text-align: left;
border-radius: var(--radius-8);
@media (hover: hover) {
&:hover {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
}
&:active {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 15%
);
}
&.selected {
color: var(--color-accent);
}
&.focused {
background: color-mix(
in srgb,
var(--color-surface-opaque),
var(--color-text) 8%
);
}
.option_check {
flex-shrink: 0;
font-size: 16px;
}
}
}
&.open {
.select_trigger {
.trigger_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.trigger_label {
top: 0;
left: var(--space-20);
transform: translateY(-70%) scale(0.875);
font-weight: var(--weight-600);
}
.trigger_caret {
transform: rotate(90deg);
}
}
.select_options {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
}
&.active {
.select_trigger {
.trigger_fieldset {
border-color: var(--color-contour-accent);
.fieldset_legend {
width: auto;
padding: 0 var(--space-12);
}
}
.trigger_label {
top: 0;
left: var(--space-20);
transform: translateY(-70%) scale(0.875);
font-weight: var(--weight-600);
}
}
}
&.open_up {
.select_options {
top: auto;
bottom: calc(100% + var(--space-20));
}
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
/* After .open and .active so it outranks them. */
&:has(.select_trigger:focus-visible) .trigger_fieldset {
border-color: var(--color-accent);
border-width: 2px;
}
&.error {
/* Last on purpose: it ties with :focus-visible/.open/.active and wins by source order. */
.select_trigger .trigger_fieldset {
border-color: var(--color-error);
}
&.open .select_trigger .trigger_fieldset,
&.active .select_trigger .trigger_fieldset {
border-color: color-mix(in srgb, var(--color-error) 99%, transparent);
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| label | string | required | Floating field label (centered when empty, rises when open or with a selection). |
| size | "xs" | "sm" | "md" | "lg" | ResponsiveSize | required | Trigger height and text size, which the list inherits. Fixed or responsive. |
| maxVisible | number | required | Number of whole options visible before scrolling. When there are more, part of the next one peeks in. |
| required | boolean | — | Marks the field as required (adds * to the label and aria-required). |
| error | boolean | — | Visual error state (red border, aria-invalid). |
| disabled | boolean | — | Disables the field (does not open, reduced opacity). |
| options | { name: string; value: string | number }[] | required | Dropdown options (visible name + value). |
| value | string | number | required | Selected value (controlled). 0 = no selection. |
| onChange | (value: string | number) => void | required | Callback with the value of the chosen option. |