Skip to content
Plinth

Forms

Searchable Select

A complete searchable combobox with keyboard navigation, disabled options, accent-insensitive filtering and real form values.

#select#combobox#search#keyboard#accessible

Install

npx shadcn@latest add https://plinthui.com/r/searchable-select.json
Open in v0
'use client';

import { useEffect, useId, useRef, useState } from 'react';

const css = `.ss-root {
  --ss-paper: #fffdf8; --ss-ink: #322e25; --ss-muted: #777064; --ss-line: #e5dfd3; --ss-brass: #806224; --ss-wash: #f2ead8;
  position: relative; width: min(100%, 360px); color: var(--ss-ink); font: 13px/1.5 system-ui, sans-serif;
}
.ss-root *, .ss-root *::before, .ss-root *::after { box-sizing: border-box; }
.ss-label { display: block; margin-bottom: 7px; color: var(--ss-ink); font-size: 12px; font-weight: 650; letter-spacing: .01em; }
.ss-control { position: relative; display: flex; align-items: center; min-height: 48px; border: 1px solid var(--ss-line); border-radius: 12px; background: var(--ss-paper); box-shadow: 0 2px 3px #33261005; transition: border-color 150ms, box-shadow 150ms; }
.ss-control:focus-within { border-color: var(--ss-brass); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ss-brass) 14%, transparent); }
.ss-search-icon { flex: none; width: 16px; height: 16px; margin-left: 14px; color: var(--ss-muted); }
.ss-input { min-width: 0; width: 100%; height: 46px; padding: 0 10px; border: 0; outline: 0; background: transparent; color: var(--ss-ink); font: inherit; text-overflow: ellipsis; }
.ss-input::placeholder { color: var(--ss-muted); opacity: 1; }
.ss-toggle { display: grid; place-items: center; flex: none; width: 40px; height: 40px; margin-right: 3px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--ss-muted); cursor: pointer; }
.ss-toggle svg { width: 15px; height: 15px; transition: transform 150ms; }
.ss-root[data-open="true"] .ss-toggle svg { transform: rotate(180deg); }
.ss-toggle:hover { background: var(--ss-wash); color: var(--ss-brass); }
.ss-root[data-disabled="true"] { opacity: .55; }
.ss-root[data-disabled="true"] .ss-control, .ss-root[data-disabled="true"] input, .ss-root[data-disabled="true"] button { cursor: not-allowed; }
.ss-popup { position: absolute; z-index: 30; top: calc(100% + 8px); left: 0; width: 100%; padding: 5px; border: 1px solid var(--ss-line); border-radius: 14px; background: var(--ss-paper); box-shadow: 0 6px 12px #33261008, 0 18px 40px #33261012; transform-origin: top; animation: ss-enter 140ms ease-out; }
.ss-popup[hidden] { display: none; }
.ss-list { max-height: 244px; overflow-y: auto; overscroll-behavior: contain; scroll-padding: 5px; }
.ss-option { display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-radius: 9px; cursor: pointer; }
.ss-option[data-active="true"] { background: var(--ss-wash); }
.ss-option[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
.ss-option-copy { flex: 1; min-width: 0; }
.ss-option-label { display: block; overflow-wrap: anywhere; font-weight: 550; }
.ss-option-description { display: block; margin-top: 1px; color: var(--ss-muted); font-size: 11px; overflow-wrap: anywhere; }
.ss-option-detail { flex: none; max-width: 35%; color: var(--ss-muted); font: 10px/1.4 ui-monospace, monospace; text-align: right; overflow-wrap: anywhere; }
.ss-check { flex: none; width: 15px; height: 15px; color: var(--ss-brass); }
.ss-empty { margin: 0; padding: 24px 14px; color: var(--ss-muted); font-size: 12px; text-align: center; }
.ss-footer { display: flex; justify-content: space-between; gap: 8px; padding: 9px 10px 4px; border-top: 1px solid var(--ss-line); color: var(--ss-muted); font: 9px/1.5 ui-monospace, monospace; }
.ss-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.ss-source { width: 100%; padding: 10px; border: 1px solid var(--ss-line); border-radius: 10px; color: var(--ss-ink); background: var(--ss-paper); font: inherit; }
.ss-root[data-ready="true"] .ss-source { display: none; }
:root[data-theme="dark"] .ss-root { --ss-paper: #28261f; --ss-ink: #eee8d9; --ss-muted: #b4ac9d; --ss-line: #494439; --ss-brass: #d7b569; --ss-wash: #3b3323; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .ss-root { --ss-paper: #28261f; --ss-ink: #eee8d9; --ss-muted: #b4ac9d; --ss-line: #494439; --ss-brass: #d7b569; --ss-wash: #3b3323; } }
@keyframes ss-enter { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .ss-popup { animation: none; } .ss-control, .ss-toggle svg { transition: none; } }
`;

export type SelectOption = {
  /** Stable, unique value used by forms and callbacks. */
  value: string;
  label: string;
  description?: string;
  detail?: string;
  keywords?: string[];
  disabled?: boolean;
};

function searchable(option: SelectOption) {
  return [option.label, option.description, ...(option.keywords ?? [])].filter(Boolean).join(' ').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase();
}

export function SearchableSelect({
  options, label, value, defaultValue = '', onValueChange, name, disabled = false,
  placeholder = 'Choose an option', emptyMessage = 'No matches. Try another search.',
}: {
  options: SelectOption[];
  label: string;
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string, option: SelectOption) => void;
  name?: string;
  disabled?: boolean;
  placeholder?: string;
  emptyMessage?: string;
}) {
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const [internalValue, setInternalValue] = useState(defaultValue);
  const [expanded, setExpanded] = useState(false);
  const [query, setQuery] = useState('');
  const [activeValue, setActiveValue] = useState<string | null>(null);
  const [announcement, setAnnouncement] = useState('');
  const [wasDisabled, setWasDisabled] = useState(disabled);
  // A disabled field closes once; re-enabling must not reopen a stale search.
  if (wasDisabled !== disabled) {
    setWasDisabled(disabled);
    if (disabled) { setExpanded(false); setQuery(''); }
  }
  const selectedValue = value ?? internalValue;
  const selected = options.find(option => option.value === selectedValue);
  const open = expanded && !disabled;
  const normalizedQuery = query.trim().normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLocaleLowerCase();
  const filtered = options.filter(option => searchable(option).includes(normalizedQuery));
  const enabled = filtered.filter(option => !option.disabled);
  const active = enabled.find(option => option.value === activeValue) ?? enabled[0];
  const activeId = open && active ? `${id}-option-${options.indexOf(active)}` : undefined;

  function close() { setExpanded(false); setQuery(''); }

  function begin(last = false) {
    if (disabled) return;
    const available = options.filter(option => !option.disabled);
    setQuery('');
    setActiveValue(available.find(option => option.value === selectedValue)?.value ?? available[last ? available.length - 1 : 0]?.value ?? null);
    setExpanded(true);
  }

  function choose(option: SelectOption) {
    if (option.disabled || disabled) return;
    if (value === undefined) setInternalValue(option.value);
    setAnnouncement(`${option.label} selected.`);
    close();
    onValueChange?.(option.value, option);
  }

  useEffect(() => {
    if (!open) return;
    function outside(event: PointerEvent) {
      if (!root.current?.contains(event.target as Node)) { setExpanded(false); setQuery(''); }
    }
    document.addEventListener('pointerdown', outside);
    return () => document.removeEventListener('pointerdown', outside);
  }, [open]);

  useEffect(() => {
    if (activeId) document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' });
  }, [activeId]);

  useEffect(() => {
    const form = input.current?.form;
    if (!form || value !== undefined) return;
    let mounted = true;
    function reset(event: Event) {
      // Wait for other handlers to cancel the native reset if necessary.
      queueMicrotask(() => {
        if (!mounted || event.defaultPrevented) return;
        setInternalValue(defaultValue);
        setExpanded(false);
        setQuery('');
        setAnnouncement('Selection reset.');
      });
    }
    form.addEventListener('reset', reset);
    return () => { mounted = false; form.removeEventListener('reset', reset); };
  }, [defaultValue, value]);

  return <>
    <style>{css}</style>
    <div ref={root} className="ss-root" data-open={open} data-disabled={disabled}
      onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) close(); }}>
      <label className="ss-label" htmlFor={`${id}-input`}>{label}</label>
      <div className="ss-control">
        <svg className="ss-search-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" /></svg>
        <input ref={input} id={`${id}-input`} className="ss-input" role="combobox" aria-expanded={open}
          aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={activeId}
          autoComplete="off" spellCheck={false} disabled={disabled} placeholder={selected?.label || placeholder}
          value={open ? query : selected?.label ?? ''}
          onFocus={() => begin()}
          onClick={() => { if (!open) begin(); }}
          onChange={event => { setQuery(event.target.value); setActiveValue(null); setExpanded(true); }}
          onKeyDown={event => {
            if (event.nativeEvent.isComposing) return;
            if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
              event.preventDefault();
              if (!open) { begin(event.key === 'ArrowUp'); return; }
              if (!enabled.length) return;
              const index = active ? enabled.indexOf(active) : -1;
              const next = (index + (event.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length;
              setActiveValue(enabled[next].value);
            } else if (open && (event.key === 'Home' || event.key === 'End') && !event.shiftKey) {
              event.preventDefault();
              setActiveValue(enabled[event.key === 'Home' ? 0 : enabled.length - 1]?.value ?? null);
            } else if (open && event.key === 'Enter') {
              event.preventDefault();
              if (active) choose(active);
            } else if (open && event.key === 'Escape') {
              event.preventDefault(); event.stopPropagation(); close();
            } else if (event.key === 'Tab') close();
          }} />
        <button className="ss-toggle" type="button" tabIndex={-1} aria-label={open ? 'Close options' : 'Show options'} disabled={disabled}
          onMouseDown={event => event.preventDefault()} onClick={() => { input.current?.focus(); if (open) close(); else begin(); }}>
          <svg aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="m5 7.5 5 5 5-5" /></svg>
        </button>
      </div>
      <div className="ss-popup" hidden={!open}>
        <div id={`${id}-list`} className="ss-list" role="listbox" aria-label={label}>
          {filtered.map(option => <div key={option.value} id={`${id}-option-${options.indexOf(option)}`}
            className="ss-option" role="option" aria-selected={selectedValue === option.value} aria-disabled={option.disabled || undefined}
            data-active={active?.value === option.value} onMouseDown={event => event.preventDefault()}
            onPointerMove={() => { if (!option.disabled) setActiveValue(option.value); }} onClick={() => choose(option)}>
            <span className="ss-option-copy"><span className="ss-option-label">{option.label}</span>{option.description && <span className="ss-option-description">{option.description}</span>}</span>
            {(option.disabled || option.detail) && <span className="ss-option-detail">{option.disabled ? 'Unavailable' : option.detail}</span>}
            {selectedValue === option.value && <svg className="ss-check" aria-hidden="true" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m4 10 4 4 8-8" /></svg>}
          </div>)}
        </div>
        {filtered.length === 0 && <p className="ss-empty">{emptyMessage}</p>}
        <div className="ss-footer" aria-hidden="true"><span>↑↓ explore · Enter choose</span><span>Esc closes</span></div>
      </div>
      <span role="status" className="ss-sr">{open ? `${enabled.length} ${enabled.length === 1 ? 'option' : 'options'} available.` : announcement}</span>
      {name && <input type="hidden" name={name} value={selected?.value ?? ''} disabled={disabled} />}
    </div>
  </>;
}

More in Forms

Similar tags