Ir al contenido
Evaluar mi proyecto
Componentes · FormulariosDisponible

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.

select-personalizado.tsx133 líneas
Descargar
"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.

terminal.sh1 líneas
npx shadcn@latest add https://www.gassierconsulting.com/r/select-personalizado

Ejemplo de uso de Select neobrutalista

Después de copiar select-personalizado.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
label?stringOpcional.
options?SelectPersonalizadoOpcion[]Opcional.
value?stringOpcional.
defaultValue?stringOpcional.
placeholder?stringOpcional.
onChange?(value: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)