Select
Un selector desplegable para elegir de una lista de opciones. Soporta comportamiento nativo del navegador para accesibilidad y compatibilidad móvil, con variantes personalizadas para casos de uso avanzados.
Casos de uso
- Selectores de país o región en formularios de dirección
- Filtros de categoría en páginas de listados de productos
- Selección de roles en paneles de gestión de usuarios
- Controles de ordenar en tablas de datos
- Selectores de idioma o configuración regional
- Selectores de prioridad o estado en gestión de tareas
- Selectores de zona horaria en interfaces de programación
Implementación más simple
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}
export function Select({ label, value, onChange, options }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Un select etiquetado mínimo. El callback onChange devuelve el valor de cadena directamente para que el padre no necesite desempacar e.target.value. El elemento nativo <select> proporciona navegación por teclado integrada y selectores amigables para móviles de forma gratuita.
Variaciones
Select nativo con etiqueta
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
required?: boolean;
}
export function Select({ label, value, onChange, options, required }: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">
{label}
{required && <span className="ml-1 text-red-500">*</span>}
</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
className="mt-1 block w-full appearance-none rounded-lg border border-gray-300 bg-white bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%236b7280%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%2001.02-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat px-3 py-2 pr-10 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Utiliza appearance-none para eliminar la flecha predeterminada del navegador y la reemplaza con un chevron SVG en línea a través de background-image. Esto da control total sobre el estilo del indicador desplegable mientras retiene el comportamiento nativo del select.
Con marcador
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
placeholder?: string;
}
export function Select({
label,
value,
onChange,
options,
placeholder = "Select an option",
}: SelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className={`mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 ${
value === "" ? "text-gray-400" : "text-gray-900"
}`}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
);
}Una opción deshabilitada en primer lugar actúa como marcador. El color del texto es gris cuando no hay nada seleccionado y cambia al color normal una vez que se elige una opción. Usar disabled en la opción de marcador evita que se vuelva a seleccionar después de que el usuario elige un valor.
Con grupos de opciones
"use client";
interface OptionGroup {
label: string;
options: { value: string; label: string }[];
}
interface GroupedSelectProps {
label: string;
value: string;
onChange: (value: string) => void;
groups: OptionGroup[];
}
export function GroupedSelect({ label, value, onChange, groups }: GroupedSelectProps) {
return (
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="" disabled>
Selecciona una opción
</option>
{groups.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
</label>
);
}Utiliza el elemento nativo <optgroup> para agrupar visualmente opciones relacionadas bajo encabezados en negrita. Esto es ideal para listas de opciones largas como países agrupados por continente o categorías agrupadas por departamento.
Multi-Select (Personalizado)
"use client";
import { useState, useRef, useEffect } from "react";
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
label: string;
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
}
export function MultiSelect({ label, options, selected, onChange }: MultiSelectProps) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function toggleOption(value: string) {
onChange(
selected.includes(value)
? selected.filter((v) => v !== value)
: [...selected, value]
);
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={selected.length === 0 ? "text-gray-400" : "text-gray-900"}>
{selected.length === 0
? "Selecciona opciones"
: `${selected.length} seleccionadas`}
</span>
<svg
className={`h-4 w-4 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<ul className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
{options.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => toggleOption(opt.value)}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-gray-50"
>
<span
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${
selected.includes(opt.value)
? "border-blue-600 bg-blue-600 text-white"
: "border-gray-300"
}`}
>
{selected.includes(opt.value) && (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</span>
{opt.label}
</button>
</li>
))}
</ul>
)}
</div>
);
}Un dropdown multi-select personalizado construido con botones y una lista de popover. Al hacer clic fuera, se cierra el dropdown a través de un listener mousedown. Cada opción renderiza un indicador visual de casilla. El botón de activación muestra el número de elementos seleccionados.
Buscable/Filtrable
"use client";
import { useState, useRef, useEffect, useMemo } from "react";
interface Option {
value: string;
label: string;
}
interface SearchableSelectProps {
label: string;
options: Option[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function SearchableSelect({
label,
options,
value,
onChange,
placeholder = "Search...",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const filtered = useMemo(
() => options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())),
[options, query]
);
const selectedLabel = options.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
function handleSelect(optionValue: string) {
onChange(optionValue);
setOpen(false);
setQuery("");
}
return (
<div ref={containerRef} className="relative">
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
<button
type="button"
onClick={() => {
setOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}}
className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<span className={value ? "text-gray-900" : "text-gray-400"}>
{selectedLabel || "Selecciona una opción"}
</span>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<ul className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-500">No se encontraron resultados</li>
) : (
filtered.map((opt) => (
<li key={opt.value}>
<button
type="button"
onClick={() => handleSelect(opt.value)}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 ${
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "text-gray-900"
}`}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
)}
</div>
);
}Un dropdown con una entrada de búsqueda que filtra opciones mientras el usuario escribe. La opción seleccionada se resalta en la lista con un fondo azul. El hook useMemo evita volver a filtrar en cada renderizado. El enfoque se mueve a la entrada de búsqueda al abrir usando un setTimeout para esperar a que el DOM se actualice.
Controlado con estado de error
"use client";
interface SelectProps {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
error?: string;
disabled?: boolean;
}
export function Select({ label, value, onChange, options, error, disabled }: SelectProps) {
return (
<div className="block">
<label className="block">
<span className="text-sm font-medium text-gray-700">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-invalid={!!error}
className={`mt-1 block w-full rounded-lg border bg-white px-3 py-2 text-sm shadow-sm focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 ${
error
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500"
}`}
>
<option value="" disabled>
Selecciona una opción
</option>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}Combina valor controlado, visualización de error y estado deshabilitado. El atributo aria-invalid informa a los lectores de pantalla del error de validación. El estilo deshabilitado evita la interacción mientras mantiene el valor seleccionado visible.
Implementación compleja
"use client";
import { forwardRef, useId, useState, useRef, useEffect, useCallback, useMemo } from "react";
type SelectSize = "sm" | "md" | "lg";
interface Option {
value: string;
label: string;
disabled?: boolean;
}
interface OptionGroup {
label: string;
options: Option[];
}
interface SelectProps {
label?: string;
helperText?: string;
error?: string;
size?: SelectSize;
options?: Option[];
groups?: OptionGroup[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
disabled?: boolean;
fullWidth?: boolean;
id?: string;
}
const sizeClasses: Record<SelectSize, { trigger: string; text: string; option: string }> = {
sm: { trigger: "h-8 px-2.5 text-xs", text: "text-xs", option: "px-2.5 py-1.5 text-xs" },
md: { trigger: "h-10 px-3 text-sm", text: "text-sm", option: "px-3 py-2 text-sm" },
lg: { trigger: "h-12 px-4 text-base", text: "text-base", option: "px-4 py-2.5 text-base" },
};
export const Select = forwardRef<HTMLButtonElement, SelectProps>(function Select(
{
label,
helperText,
error,
size = "md",
options = [],
groups,
value,
onChange,
placeholder = "Selecciona una opción",
searchable = false,
disabled = false,
fullWidth = true,
id: externalId,
},
ref
) {
const generatedId = useId();
const selectId = externalId ?? generatedId;
const errorId = `${selectId}-error`;
const helperId = `${selectId}-helper`;
const listboxId = `${selectId}-listbox`;
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const allOptions = useMemo(() => {
if (groups) {
return groups.flatMap((g) => g.options);
}
return options;
}, [options, groups]);
const filtered = useMemo(() => {
if (!searchable || !query) return allOptions;
return allOptions.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
}, [allOptions, query, searchable]);
const selectedLabel = allOptions.find((o) => o.value === value)?.label ?? "";
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
},
[onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!open) {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
return;
}
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex((i) =>
i < filtered.length - 1 ? i + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex((i) =>
i > 0 ? i - 1 : filtered.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filtered[highlightedIndex]) {
const opt = filtered[highlightedIndex];
if (!opt.disabled) handleSelect(opt.value);
}
break;
case "Escape":
setOpen(false);
setQuery("");
setHighlightedIndex(-1);
break;
}
},
[open, filtered, highlightedIndex, handleSelect]
);
const hasError = !!error;
const sizes = sizeClasses[size];
return (
<div
ref={containerRef}
className={fullWidth ? "w-full" : "inline-flex flex-col"}
onKeyDown={handleKeyDown}
>
{label && (
<label
htmlFor={selectId}
className={`mb-1 block font-medium text-gray-700 ${sizes.text}`}
>
{label}
</label>
)}
<button
ref={ref}
id={selectId}
type="button"
role="combobox"
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={listboxId}
aria-invalid={hasError}
aria-describedby={
[hasError ? errorId : null, helperText ? helperId : null]
.filter(Boolean)
.join(" ") || undefined
}
disabled={disabled}
onClick={() => {
if (!disabled) {
setOpen((o) => !o);
if (searchable) setTimeout(() => inputRef.current?.focus(), 0);
}
}}
className={[
"flex items-center justify-between rounded-lg border shadow-sm transition-colors",
"focus:outline-none focus:ring-1",
"disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500",
sizes.trigger,
fullWidth ? "w-full" : "",
hasError
? "border-red-500 focus:border-red-500 focus:ring-red-500"
: "border-gray-300 bg-white focus:border-blue-500 focus:ring-blue-500",
]
.filter(Boolean)
.join(" ")}
>
<span className={value ? "text-gray-900 truncate" : "text-gray-400 truncate"}>
{selectedLabel || placeholder}
</span>
<svg
className={`ml-2 h-4 w-4 shrink-0 text-gray-400 transition-transform ${open ? "rotate-180" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open && (
<div className="relative">
<div className="absolute z-20 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg">
{searchable && (
<div className="border-b border-gray-100 p-2">
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
}}
placeholder="Buscar..."
className="w-full rounded-md border border-gray-200 px-2 py-1.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
<ul id={listboxId} role="listbox" className="max-h-60 overflow-auto py-1">
{filtered.length === 0 ? (
<li className={`${sizes.option} text-gray-500`}>No se encontraron resultados</li>
) : (
filtered.map((opt, idx) => (
<li
key={opt.value}
role="option"
aria-selected={opt.value === value}
aria-disabled={opt.disabled}
>
<button
type="button"
onClick={() => !opt.disabled && handleSelect(opt.value)}
className={[
"w-full text-left",
sizes.option,
opt.disabled
? "cursor-not-allowed text-gray-400"
: "hover:bg-gray-50",
opt.value === value ? "bg-blue-50 font-medium text-blue-600" : "",
idx === highlightedIndex ? "bg-gray-100" : "",
]
.filter(Boolean)
.join(" ")}
>
{opt.label}
</button>
</li>
))
)}
</ul>
</div>
</div>
)}
<div className="mt-1">
{hasError && (
<p id={errorId} className={`text-red-600 ${sizes.text}`} role="alert">
{error}
</p>
)}
{!hasError && helperText && (
<p id={helperId} className={`text-gray-500 ${sizes.text}`}>
{helperText}
</p>
)}
</div>
</div>
);
});Aspectos clave:
- forwardRef -- permite que los componentes padre adjunten refs para gestión de enfoque y control imperativo del botón de activación.
- Navegación por teclado -- soporta las teclas ArrowUp, ArrowDown, Enter y Escape con un rastreador de índice resaltado. Esto hace que el select personalizado sea accesible sin depender del comportamiento nativo de
<select>. - Patrón ARIA combobox -- el activador utiliza
role="combobox",aria-expanded,aria-haspopup="listbox"yaria-controlspara comunicar el estado del dropdown a la tecnología de asistencia. - Modo buscable -- cuando
searchablees true, aparece una entrada de texto en la parte superior del dropdown y filtra opciones conuseMemopara mejorar el rendimiento. El enfoque se mueve automáticamente a la entrada al abrir. - Soporte de grupos de opciones -- acepta
optionsplanas ugroupsestructurados, aplanados internamente conuseMemopara que el filtrado y la navegación por teclado funcionen de manera uniforme. - Opciones deshabilitadas -- las opciones individuales pueden estar deshabilitadas con
aria-disabledy un estilo atenuado, evitando la selección mientras permanecen visibles en la lista. - Cierre al hacer clic fuera -- un listener
mousedowncierra el dropdown al hacer clic fuera del contenedor, limpiado al desmontar para evitar fugas de memoria.
Problemas comunes
-
Limitaciones de estilo del select nativo -- El elemento
<option>no se puede estilizar con CSS en la mayoría de los navegadores. Si necesitas renderizado personalizado de opciones (iconos, colores, descripciones), debes construir un dropdown personalizado en lugar de usar<select>nativo. -
Opción de marcador re-seleccionable -- Una
<option value="" disabled>no se puede volver a seleccionar después de que el usuario elige un valor a menos que agregues un mecanismo separado de "limpiar". Considera si tu UX necesita un botón de reinicio. -
Contexto de apilamiento z-index -- Los menús dropdown personalizados necesitan un
z-indexlo suficientemente alto para aparecer sobre otros elementos posicionados. Si el dropdown aparece detrás de un hermano, verifica si hayoverflow: hiddenen contenedores ancestros o crea un portal. -
Experiencia móvil con selects personalizados -- El
<select>nativo proporciona el selector nativo de la plataforma en móvil (spinner en Android, rueda en iOS). Los dropdowns personalizados pierden este beneficio. Considera usar un select nativo en móvil y uno personalizado en escritorio. -
Datos de formulario con selects personalizados -- Los selects personalizados construidos con botones no envían valores con envío nativo de formularios. Agrega un
<input type="hidden" name="..." value={value} />dentro del formulario para incluir el valor enFormData. -
onChange se activa con el mismo valor -- El
<select>nativo no activaonChangecuando el usuario vuelve a seleccionar la opción ya seleccionada. Si necesitas detectar re-selección, usa un manejadoronClicken opciones en lugar de eso.
Relacionado
- Input -- Entrada de texto con convenciones compartidas de estilo de formulario
- Dropdown -- Menú dropdown de propósito general para acciones
- Button -- Botones de activación usados con selects personalizados
- Forms -- Patrones de formularios y estrategias de validación
- Event Handling -- Tipado de onChange y eventos