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>
  );
}

Props

PropTypeRequiredDescription
labelstringrequiredFloating field label (centered when empty, rises when open or with a selection).
size"xs" | "sm" | "md" | "lg" | ResponsiveSizerequiredTrigger height and text size, which the list inherits. Fixed or responsive.
maxVisiblenumberrequiredNumber of whole options visible before scrolling. When there are more, part of the next one peeks in.
requiredboolean—Marks the field as required (adds * to the label and aria-required).
errorboolean—Visual error state (red border, aria-invalid).
disabledboolean—Disables the field (does not open, reduced opacity).
options{ name: string; value: string | number }[]requiredDropdown options (visible name + value).
valuestring | numberrequiredSelected value (controlled). 0 = no selection.
onChange(value: string | number) => voidrequiredCallback with the value of the chosen option.