Componentpickers1.92 KB gzipZero dependencies

Select

Custom dashed listbox dropdown with keyboard navigation.

Preview

Live Preview

Installation

Tokens guide →

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

npx bigbullui add select

Or install the full npm package:

npm install bigbullui
import { Select } from "@/components/ui/select";

<Select
  options={[
    { value: "a", label: "Option A" },
    { value: "b", label: "Option B" },
  ]}
  placeholder="Select an option"
/>

Usage guidance

Use Select when a ticket stub surface needs Custom dashed listbox dropdown with keyboard navigation. Keep props minimal and prefer composition with sibling stubs.

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

Source code

select.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 interface SelectOption {
  value: string;
  label: string;
}

export interface SelectProps {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  placeholder?: string;
  disabled?: boolean;
  name?: string;
  className?: string;
  "aria-label"?: string;
  /** Dual-mode: false renders instantly with no motion. Default true. */
  animated?: boolean;
}

export function Select({
  options,
  value: controlledValue,
  defaultValue = "",
  onValueChange,
  placeholder = "Select an option",
  disabled = false,
  name,
  className,
  "aria-label": ariaLabel,
  animated = true,
}: SelectProps) {
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const selectedValue = isControlled ? controlledValue : uncontrolledValue;

  const [open, setOpen] = React.useState(false);
  const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
  const containerRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);

  const selectedOption = options.find((opt) => opt.value === selectedValue);

  const handleSelect = React.useCallback(
    (val: string) => {
      if (!isControlled) {
        setUncontrolledValue(val);
      }
      onValueChange?.(val);
      setOpen(false);
      triggerRef.current?.focus();
    },
    [isControlled, onValueChange]
  );

  React.useEffect(() => {
    function handleClickOutside(e: MouseEvent) {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    if (open) {
      document.addEventListener("mousedown", handleClickOutside);
      return () => document.removeEventListener("mousedown", handleClickOutside);
    }
  }, [open]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (disabled) return;
    if (e.key === "Escape") {
      setOpen(false);
      triggerRef.current?.focus();
    } else if (e.key === "ArrowDown") {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setHighlightedIndex(0);
      } else {
        setHighlightedIndex((prev) => (prev < options.length - 1 ? prev + 1 : 0));
      }
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setHighlightedIndex(options.length - 1);
      } else {
        setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : options.length - 1));
      }
    } else if (e.key === "Enter" || e.key === " ") {
      if (open && highlightedIndex >= 0 && highlightedIndex < options.length) {
        e.preventDefault();
        handleSelect(options[highlightedIndex].value);
      } else if (!open) {
        e.preventDefault();
        setOpen(true);
      }
    }
  };

  return (
    <div ref={containerRef} className={cn("relative inline-block w-full max-w-xs", className)}>
      {name ? <input type="hidden" name={name} value={selectedValue} /> : null}
      <button
        ref={triggerRef}
        type="button"
        disabled={disabled}
        aria-haspopup="listbox"
        aria-expanded={open}
        aria-label={ariaLabel}
        onClick={() => setOpen((prev) => !prev)}
        onKeyDown={handleKeyDown}
        className={cn(
          "flex h-10 w-full items-center justify-between rounded-md border-2 border-dashed border-border bg-card px-3 py-2 text-start font-mono text-sm transition-colors",
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-foreground",
          disabled && "cursor-not-allowed opacity-50",
          !disabled && "cursor-pointer hover:border-foreground/50"
        )}
      >
        <span className={cn("truncate", !selectedOption && "text-muted-foreground")}>
          {selectedOption ? selectedOption.label : placeholder}
        </span>
        <svg
          className={cn("ms-2 size-4 shrink-0", animated ? "transition-transform duration-150 motion-reduce:transition-none" : "transition-none", open && "rotate-180")}
          viewBox="0 0 16 16"
          fill="none"
          stroke="currentColor"
          strokeWidth="2"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden="true"
        >
          <path d="m4 6 4 4 4-4" />
        </svg>
      </button>

      {open ? (
        <ul
          role="listbox"
          tabIndex={-1}
          className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-card p-1 shadow-md outline-1 outline-dashed outline-offset-[-4px]"
        >
          {options.map((option, index) => {
            const isSelected = option.value === selectedValue;
            const isHighlighted = index === highlightedIndex;
            return (
              <li
                key={option.value}
                role="option"
                aria-selected={isSelected}
                onClick={() => handleSelect(option.value)}
                onMouseEnter={() => setHighlightedIndex(index)}
                className={cn(
                  "relative flex cursor-pointer select-none items-center justify-between rounded-sm px-3 py-2 text-xs font-mono transition-colors",
                  isHighlighted && "bg-secondary text-secondary-foreground",
                  isSelected && "bg-accent font-bold text-accent-foreground",
                  isSelected && isHighlighted && "bg-accent/90"
                )}
              >
                <span className="truncate">{option.label}</span>
                {isSelected ? (
                  <span className="ms-2 font-mono text-[10px] uppercase tracking-wider text-accent-foreground">
                    ✓
                  </span>
                ) : null}
              </li>
            );
          })}
        </ul>
      ) : null}
    </div>
  );
}

Props

PropTypeDescription
optionsSelectOption[]
value?string
defaultValue?string
onValueChange?(value: string) => void
placeholder?string
disabled?boolean
name?string
className?string
label?string
animated?booleanDual-mode: false renders instantly with no motion. Default true.