Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Botón dividido con menú

Acción principal y menú de alternativas con teclado completo: flechas, Inicio, Fin, Escape y devolución del foco.

Código completo

Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.

boton-dividido.tsx135 líneas
Descargar
"use client";

import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";

/** Botón dividido: una acción principal y un menú de alternativas con teclado completo. Estilo minimalista de contraste. React + TypeScript, sin dependencias. */
export type BotonDivididoItem = { id: string; label: string; hint?: string };

export type BotonDivididoProps = {
  label?: string;
  items?: BotonDivididoItem[];
  onPrimary?: () => void;
  onSelect?: (id: string) => void;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcdiv-root { box-sizing: border-box; position: relative; display: inline-block; max-width: 100%; font: 500 14px/1.3 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; color: #111; }
.gcdiv-root *, .gcdiv-root *::before, .gcdiv-root *::after { box-sizing: border-box; }
.gcdiv-root button { font: inherit; -webkit-tap-highlight-color: transparent; }
.gcdiv-root :focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcdiv-root *, .gcdiv-root *::before, .gcdiv-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcdiv-group { display: inline-flex; border-radius: 10px; box-shadow: 0 1px 2px rgba(0, 0, 0, .18); }
.gcdiv-main, .gcdiv-more { min-height: 44px; border: 0; background: #111; color: #fff; cursor: pointer; transition: background .15s; }
.gcdiv-main { padding: 0 20px; border-radius: 10px 0 0 10px; font-weight: 600; }
.gcdiv-more { display: grid; place-items: center; width: 44px; border-radius: 0 10px 10px 0; border-left: 1px solid rgba(255, 255, 255, .22); }
.gcdiv-main:hover, .gcdiv-more:hover { background: #2a2a2e; }
.gcdiv-more svg { transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.gcdiv-more[aria-expanded="true"] svg { transform: rotate(180deg); }
.gcdiv-menu { position: absolute; z-index: 5; top: calc(100% + 6px); right: 0; min-width: 240px; padding: 6px; margin: 0; list-style: none; border: 1px solid #e3e3e6; border-radius: 12px; background: #fff; box-shadow: 0 12px 32px -8px rgba(0, 0, 0, .25), 0 2px 6px rgba(0, 0, 0, .06); transform-origin: top right; animation: gcdiv-in .18s cubic-bezier(.16, 1, .3, 1); }
.gcdiv-item { display: grid; gap: 1px; width: 100%; text-align: left; padding: 9px 12px; border: 0; border-radius: 8px; background: none; color: inherit; cursor: pointer; }
.gcdiv-item:hover, .gcdiv-item[data-active="true"] { background: #f1f2f4; }
.gcdiv-hint { font-size: 12px; color: #6a6b72; }
@keyframes gcdiv-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } }`;

const DEFAULT_ITEMS: BotonDivididoItem[] = [
  { id: "borrador", label: "Guardar como borrador", hint: "No se publica" },
  { id: "programar", label: "Programar publicación", hint: "Elige fecha y hora" },
  { id: "duplicar", label: "Publicar y duplicar", hint: "Crea una copia editable" },
];

export function BotonDividido({ label = "Publicar", items = DEFAULT_ITEMS, onPrimary, onSelect, className = "", style }: BotonDivididoProps) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const root = useRef<HTMLSpanElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const menuId = useId();

  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) refs.current[active]?.focus();
  }, [open, active]);

  function menuKey(event: KeyboardEvent) {
    if (event.key === "ArrowDown") {
      event.preventDefault();
      setActive((value) => (value + 1) % items.length);
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setActive((value) => (value - 1 + items.length) % items.length);
    } else if (event.key === "Home") {
      event.preventDefault();
      setActive(0);
    } else if (event.key === "End") {
      event.preventDefault();
      setActive(items.length - 1);
    } else if (event.key === "Escape" || event.key === "Tab") {
      setOpen(false);
      if (event.key === "Escape") trigger.current?.focus();
    }
  }

  function pick(id: string) {
    onSelect?.(id);
    setOpen(false);
    trigger.current?.focus();
  }

  return (
    <span ref={root} className={`gcdiv-root ${className}`} style={style}>
      <style>{CSS}</style>
      <span className="gcdiv-group">
        <button className="gcdiv-main" type="button" onClick={onPrimary}>
          {label}
        </button>
        <button
          ref={trigger}
          className="gcdiv-more"
          type="button"
          aria-haspopup="menu"
          aria-expanded={open}
          aria-controls={open ? menuId : undefined}
          aria-label="Más opciones"
          onClick={() => {
            setActive(0);
            setOpen((value) => !value);
          }}
          onKeyDown={(event) => {
            if (event.key === "ArrowDown" && !open) {
              event.preventDefault();
              setActive(0);
              setOpen(true);
            }
          }}
        >
          <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="m6 9 6 6 6-6" />
          </svg>
        </button>
      </span>
      {open ? (
        <ul className="gcdiv-menu" id={menuId} role="menu" onKeyDown={menuKey}>
          {items.map((item, index) => (
            <li key={item.id} role="none">
              <button ref={(node) => { refs.current[index] = node; }} className="gcdiv-item" type="button" role="menuitem" tabIndex={index === active ? 0 : -1} data-active={index === active ? "true" : undefined} onClick={() => pick(item.id)} onMouseEnter={() => setActive(index)}>
                <span>{item.label}</span>
                {item.hint ? <span className="gcdiv-hint">{item.hint}</span> : null}
              </button>
            </li>
          ))}
        </ul>
      ) : null}
    </span>
  );
}

Úsalo en tu proyecto

Instálalo con la CLI de shadcn.

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

Ejemplo de uso de Botón dividido con menú

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

ejemplo.tsx5 líneas
import { BotonDividido } from "./boton-dividido";

export default function Ejemplo() {
  return <BotonDividido label="Ejemplo" />;
}

Propiedades de BotonDividido

BotonDividido acepta 6 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.

PropiedadTipoDescripción
label?stringOpcional.
items?BotonDivididoItem[]Opcional.
onPrimary?() => voidOpcional.
onSelect?(id: string) => voidOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Accesibilidad y rendimiento

El archivo tiene 135 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Botón dividido con menú 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: menu, none, menuitem.
  • 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)