Select neobrutalista
Listbox accesible con apertura por teclado, salto por letra, Inicio y Fin; sombra dura y bordes gruesos.
País de facturación
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
/** Select personalizado con patrón listbox: abre con flechas, escribe para saltar, Escape cierra. Estilo neobrutalista. React + TypeScript, sin dependencias. */
export type SelectPersonalizadoOpcion = { value: string; label: string; hint?: string };
export type SelectPersonalizadoProps = {
label?: string;
options?: SelectPersonalizadoOpcion[];
value?: string;
defaultValue?: string;
placeholder?: string;
onChange?: (value: string) => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcsel-root { box-sizing: border-box; position: relative; display: block; width: 100%; max-width: 320px; color: #111; font: 700 15px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
.gcsel-root *, .gcsel-root *::before, .gcsel-root *::after { box-sizing: border-box; }
.gcsel-root button { font: inherit; }
@media (prefers-reduced-motion: reduce) { .gcsel-root *, .gcsel-root *::before, .gcsel-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcsel-label { display: block; margin-bottom: 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .06em; }
.gcsel-btn { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 50px; padding: 0 16px; text-align: left; color: inherit; border: 3px solid #111; border-radius: 0; background: #fff; cursor: pointer; box-shadow: 5px 5px 0 #111; transition: transform .12s, box-shadow .12s, background .12s; }
.gcsel-btn:hover { background: #ffe14d; }
.gcsel-btn:active { transform: translate(3px, 3px); box-shadow: 2px 2px 0 #111; }
.gcsel-btn:focus-visible { outline: 3px solid #2f5bff; outline-offset: 3px; }
.gcsel-btn[aria-expanded="true"] svg { transform: rotate(180deg); }
.gcsel-btn svg { transition: transform .2s; }
.gcsel-placeholder { opacity: .55; font-weight: 600; }
.gcsel-list { position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 10px); max-height: 260px; margin: 0; padding: 0; overflow-y: auto; list-style: none; border: 3px solid #111; background: #fff; box-shadow: 5px 5px 0 #111; animation: gcsel-in .14s ease-out; }
.gcsel-opt { display: flex; justify-content: space-between; gap: 12px; padding: 12px 16px; cursor: pointer; border-bottom: 2px solid #111; }
.gcsel-opt:last-child { border-bottom: 0; }
.gcsel-opt[data-active="true"] { background: #ffe14d; }
.gcsel-opt[aria-selected="true"] { background: #111; color: #fff; }
.gcsel-hint { font-weight: 600; font-size: 12px; opacity: .7; align-self: center; }
@keyframes gcsel-in { from { opacity: 0; transform: translateY(-6px); } }`;
const DEFAULT_OPTIONS: SelectPersonalizadoOpcion[] = [
{ value: "mx", label: "México", hint: "MXN" },
{ value: "co", label: "Colombia", hint: "COP" },
{ value: "es", label: "España", hint: "EUR" },
{ value: "cl", label: "Chile", hint: "CLP" },
{ value: "ar", label: "Argentina", hint: "ARS" },
{ value: "us", label: "Estados Unidos", hint: "USD" },
];
export function SelectPersonalizado({ label = "País de facturación", options = DEFAULT_OPTIONS, value, defaultValue, placeholder = "Elige una opción", onChange, className = "", style }: SelectPersonalizadoProps) {
const id = useId();
const [inner, setInner] = useState<string | undefined>(defaultValue);
const current = value ?? inner;
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const root = useRef<HTMLDivElement>(null);
const btn = useRef<HTMLButtonElement>(null);
const list = useRef<HTMLUListElement>(null);
const typed = useRef({ text: "", t: 0 });
const selected = options.find((option) => option.value === current);
useEffect(() => {
if (!open) return;
const away = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
useEffect(() => {
if (open) list.current?.querySelector<HTMLElement>('[data-active="true"]')?.scrollIntoView({ block: "nearest" });
}, [open, active]);
function openList() {
setActive(Math.max(0, options.findIndex((option) => option.value === current)));
setOpen(true);
}
function choose(index: number) {
const next = options[index];
setInner(next.value);
onChange?.(next.value);
setOpen(false);
btn.current?.focus();
}
function key(event: KeyboardEvent) {
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
event.preventDefault();
openList();
}
return;
}
if (event.key === "ArrowDown") { event.preventDefault(); setActive((v) => Math.min(options.length - 1, v + 1)); }
else if (event.key === "ArrowUp") { event.preventDefault(); setActive((v) => Math.max(0, v - 1)); }
else if (event.key === "Home") { event.preventDefault(); setActive(0); }
else if (event.key === "End") { event.preventDefault(); setActive(options.length - 1); }
else if (event.key === "Enter" || event.key === " ") { event.preventDefault(); choose(active); }
else if (event.key === "Escape") { event.preventDefault(); setOpen(false); }
else if (event.key === "Tab") setOpen(false);
else if (event.key.length === 1) {
window.clearTimeout(typed.current.t);
typed.current.text += event.key.toLowerCase();
typed.current.t = window.setTimeout(() => (typed.current.text = ""), 600);
const hit = options.findIndex((option) => option.label.toLowerCase().startsWith(typed.current.text));
if (hit >= 0) setActive(hit);
}
}
return (
<div ref={root} className={`gcsel-root ${className}`} style={style}>
<style>{CSS}</style>
<span className="gcsel-label" id={`${id}-l`}>
{label}
</span>
<button ref={btn} className="gcsel-btn" type="button" role="combobox" aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-labelledby={`${id}-l`} aria-activedescendant={open ? `${id}-o${active}` : undefined} onClick={() => (open ? setOpen(false) : openList())} onKeyDown={key}>
<span className={selected ? undefined : "gcsel-placeholder"}>{selected ? selected.label : placeholder}</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="square" aria-hidden="true">
<path d="m5 9 7 7 7-7" />
</svg>
</button>
{open ? (
<ul ref={list} className="gcsel-list" id={`${id}-list`} role="listbox" aria-labelledby={`${id}-l`}>
{options.map((option, index) => (
<li key={option.value} id={`${id}-o${index}`} className="gcsel-opt" role="option" aria-selected={option.value === current} data-active={index === active ? "true" : undefined} onMouseEnter={() => setActive(index)} onClick={() => choose(index)}>
<span>{option.label}</span>
{option.hint ? <span className="gcsel-hint">{option.hint}</span> : null}
</li>
))}
</ul>
) : null}
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/select-personalizadoEjemplo de uso de Select neobrutalista
Después de copiar select-personalizado.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { SelectPersonalizado } from "./select-personalizado";
export default function Ejemplo() {
return <SelectPersonalizado label="Ejemplo" />;
}Propiedades de SelectPersonalizado
SelectPersonalizado acepta 8 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
options? | SelectPersonalizadoOpcion[] | Opcional. |
value? | string | Opcional. |
defaultValue? | string | Opcional. |
placeholder? | string | Opcional. |
onChange? | (value: string) => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Accesibilidad y rendimiento
El archivo tiene 133 líneas (7 KB) y define 1 animación con @keyframes. Esto es lo que hace Select neobrutalista por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: combobox, listbox, option.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".