Componentpickers
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 selectOr install the full npm package:
npm install bigbulluiUsage
import { Select } from "@/components/ui/select";
<Select
options={[
{ value: "a", label: "Option A" },
{ value: "b", label: "Option B" },
]}
placeholder="Select an option"
/>Source code
select.tsxZero 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;
}
export function Select({
options,
value: controlledValue,
defaultValue = "",
onValueChange,
placeholder = "Select an option",
disabled = false,
name,
className,
"aria-label": ariaLabel,
}: 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-left 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("ml-2 size-4 shrink-0 transition-transform duration-150", 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="ml-2 font-mono text-[10px] uppercase tracking-wider text-accent-foreground">
✓
</span>
) : null}
</li>
);
})}
</ul>
) : null}
</div>
);
}
Props
| Prop | Type | Description |
|---|---|---|
| options | SelectOption[] | List of options with value and label (required). |
| value | string | Controlled selected value. |
| defaultValue | string | Initial selected value. |
| onValueChange | (value: string) => void | Called when selection changes. |
| placeholder | string | Placeholder text when nothing is selected. |
| disabled | boolean | Disables select interaction. |
| name | string | Form name for native submission. |