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 selectOr install the full npm package:
npm install bigbulluiUsage
Open in StackBlitzimport { 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.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;
/** 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
| Prop | Type | Description |
|---|---|---|
| options | SelectOption[] | |
| value? | string | |
| defaultValue? | string | |
| onValueChange? | (value: string) => void | |
| placeholder? | string | |
| disabled? | boolean | |
| name? | string | |
| className? | string | |
| label? | string | |
| animated? | boolean | Dual-mode: false renders instantly with no motion. Default true. |