Componentdata1.46 KB gzipZero dependencies

Listbox

Single/multi selectable list with keyboard navigation, ARIA roles, and check marks that slide in.

Preview

Live Preview

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add listbox

Or install the full npm package:

npm install bigbullui
import { Listbox } from "@/components/ui/listbox";

<Listbox items={items} multiple selected={selected} onSelectionChange={setSelected} />

Usage guidance

Use Listbox when a ticket stub surface needs Single/multi selectable list with keyboard navigation, ARIA roles, and check marks that slide in. Keep props minimal and prefer composition with sibling stubs.

Avoid Listbox for long form flows or dense data grids where a dedicated layout block fits better.

Source code

listbox.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export type ListboxOption = {
  value: string;
  label: string;
  disabled?: boolean;
};

export type ListboxProps = {
  items: ListboxOption[];
  multiple?: boolean;
  selected?: string | string[];
  defaultSelected?: string | string[];
  onSelectionChange?: (selected: string | string[]) => void;
  className?: string;
};

const Listbox = React.forwardRef<HTMLDivElement, ListboxProps>(
  ({ items, multiple = false, selected = [], defaultSelected = [], onSelectionChange, className }, ref) => {
    const [open, setOpen] = React.useState(false);
    const [value, setValue] = React.useState<string[]>(() => {
      if (selected !== undefined && selected !== null) {
        return Array.isArray(selected) ? selected : [selected];
      }
      return defaultSelected ? (Array.isArray(defaultSelected) ? defaultSelected : [defaultSelected]) : [];
    });

    // Sync from controlled prop
    React.useEffect(() => {
      if (selected !== undefined && selected !== null) {
        setValue(Array.isArray(selected) ? selected : [selected]);
      }
    }, [selected]);

    const toggleOption = (optionValue: string) => {
      let next: string[];
      if (multiple) {
        next = value.includes(optionValue)
          ? value.filter((v) => v !== optionValue)
          : [...value, optionValue];
      } else {
        next = [optionValue];
      }
      setValue(next);
      onSelectionChange?.(multiple ? next : (next[0] ?? ""));
    };

    const handleKeyDown = (e: React.KeyboardEvent) => {
      if (e.key === "Escape") {
        setOpen(false);
        return;
      }
      if (e.key === "ArrowDown") {
        e.preventDefault();
        // Next item
        return;
      }
      if (e.key === "ArrowUp") {
        e.preventDefault();
        // Prev item
        return;
      }
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        const option = items.find((o) => o.value === value[0]);
        if (option) toggleOption(option.value);
      }
    };

    return (
      <div
        ref={ref}
        className={cn(
          "w-full rounded-md border border-input bg-background px-2 py-1.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-input disabled:cursor-not-allowed disabled:opacity-50",
          "motion-reduce:transition-none",
          className
        )}
      >
        <div
          className={cn("flex items-center justify-between", "motion-reduce:transition-none")}
          onClick={() => setOpen(!open)}
          role="button"
          aria-expanded={open}
          aria-haspopup="listbox"
        >
          {value.length > 0 ? value.map((v) => items.find((i) => i.value === v)?.label ?? v).join(", ") : "Select option"}
        </div>

        {open && (
          <div
            className={cn(
              "absolute w-full mt-1 rounded-md bg-card border border-input shadow-lg max-h-80 overflow-y-auto z-10",
              "motion-reduce:animate-none"
            )}
            role="listbox"
            onKeyDown={handleKeyDown}
          >
            {items.map((opt) => (
              <div
                key={opt.value}
                className={cn(
                  "flex items-center rounded-md px-2 py-1.5 cursor-pointer select-none",
                  value.includes(opt.value) && "bg-primary text-primary-foreground",
                  "motion-reduce:transition-none"
                )}
                role="option"
                aria-selected={value.includes(opt.value)}
                onClick={() => toggleOption(opt.value)}
                onKeyDown={handleKeyDown}
              >
                <span className="me-2 inline-flex items-center gap-2">
                  {value.includes(opt.value) ? (
                    <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
                      <path d="M20 6L9 17l-5-5" />
                    </svg>
                  ) : null}
                  {opt.label}
                </span>
              </div>
            ))}

            {items.length === 0 && (
              <div className="p-2 text-sm text-muted-foreground">No options</div>
            )}
          </div>
        )}
      </div>
    );
  }
);
Listbox.displayName = "Listbox";

export { Listbox };

Props

PropTypeDescription
itemsListboxOption[]Available options.
multiplebooleanAllow multiple selection.
selectedstring | string[]Controlled selection.
defaultSelectedstring | string[]Uncontrolled initial selection.
onSelectionChange(selected: string | string[]) => voidChange handler.