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.
Busca en todas las páginas de la documentación
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.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
"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.
"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.
"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.
"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.
"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.
"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.
"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:
<select>.role="combobox", aria-expanded, aria-haspopup="listbox" y aria-controls para comunicar el estado del dropdown a la tecnología de asistencia.searchable es true, aparece una entrada de texto en la parte superior del dropdown y filtra opciones con useMemo para mejorar el rendimiento. El enfoque se mueve automáticamente a la entrada al abrir.options planas u groups estructurados, aplanados internamente con useMemo para que el filtrado y la navegación por teclado funcionen de manera uniforme.aria-disabled y un estilo atenuado, evitando la selección mientras permanecen visibles en la lista.mousedown cierra el dropdown al hacer clic fuera del contenedor, limpiado al desmontar para evitar fugas de memoria.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-index lo suficientemente alto para aparecer sobre otros elementos posicionados. Si el dropdown aparece detrás de un hermano, verifica si hay overflow: hidden en 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 en FormData.
onChange se activa con el mismo valor -- El <select> nativo no activa onChange cuando el usuario vuelve a seleccionar la opción ya seleccionada. Si necesitas detectar re-selección, usa un manejador onClick en opciones en lugar de eso.
Revisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥