Listbox
Custom select dropdown with click-to-open menu, selected state indicators, and keyboard dismiss.
Preview
Basic listbox
import { Listbox } from '@/registry/ui-kit/tailwind-listbox/react'
function Example() {
const [value, setValue] = useState('')
return (
<Listbox
options={[
{ value: 'admin', label: 'Admin' },
{ value: 'editor', label: 'Editor' },
{ value: 'viewer', label: 'Viewer' },
]}
value={value}
onChange={setValue}
placeholder="Select a role..."
/>
)
}Component API
| Prop | Default | Description |
|---|---|---|
Listbox | — | Custom select dropdown that shows the selected label with a chevron trigger and option list. |
Source Code
"use client";
import React, { useState, useRef, useEffect } from "react";
interface ListboxOption {
value: string;
label: string;
}
interface ListboxProps {
options: ListboxOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export function Listbox({
options,
value,
onChange,
placeholder = "Select an option...",
className = "",
}: ListboxProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const selectedOption = options.find((o) => o.value === value);
// Click outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
// Escape key
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") setIsOpen(false);
}
if (isOpen) {
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}
}, [isOpen]);
return (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-haspopup="listbox"
className="flex w-full items-center justify-between rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 outline-none transition-colors focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:focus:border-indigo-400 dark:focus:ring-indigo-400/20"
>
<span className={selectedOption ? "" : "text-zinc-400 dark:text-zinc-500"}>
{selectedOption?.label ?? placeholder}
</span>
<svg
className={`h-4 w-4 text-zinc-400 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{isOpen && (
<ul
role="listbox"
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-zinc-200 bg-white py-1 shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
>
{options.map((option) => {
const isSelected = option.value === value;
return (
<li
key={option.value}
role="option"
aria-selected={isSelected}
className={`flex cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 ${
isSelected ? "font-medium text-indigo-600 dark:text-indigo-400" : "text-zinc-700 dark:text-zinc-300"
}`}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{/* Checkmark on left for selected */}
<span className="w-4 flex-shrink-0">
{isSelected && (
<svg
className="h-4 w-4 text-indigo-600 dark:text-indigo-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</span>
<span>{option.label}</span>
</li>
);
})}
</ul>
)}
</div>
);
}