Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Botón de acción asíncrona

Ejecuta una promesa con estados de carga, éxito y error; permite reintentar y evita acciones duplicadas.

Código completo

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

boton-asincrono.tsx117 líneas
Descargar
"use client";

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

/** Botón de acción asíncrona. React + TypeScript, sin dependencias. Personaliza con variables --gcxa-*. */
export type BotonAsincronoProps = {
  label?: string;
  action?: () => Promise<void>;
  disabled?: boolean;
  className?: string;
  style?: CSSProperties;
};

const CSS = `
.gcxa-root { box-sizing: border-box; min-width: 0; max-width: 100%; color: var(--gcxa-fg, #202421); font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.gcxa-root *, .gcxa-root *::before, .gcxa-root *::after { box-sizing: border-box; }
.gcxa-root button, .gcxa-root input, .gcxa-root textarea, .gcxa-root select { font: inherit; }
.gcxa-root button, .gcxa-root a { -webkit-tap-highlight-color: transparent; }
.gcxa-root :focus-visible { outline: 2px solid var(--gcxa-accent, #25724b); outline-offset: 4px; }
.gcxa-root ::selection { background: var(--gcxa-accent, #25724b); color: #fff; }
.gcxa-root button:disabled { opacity: .5; cursor: not-allowed; }
.gcxa-root svg { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .gcxa-root *, .gcxa-root *::before, .gcxa-root *::after { animation: none !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gcxa-root { text-align: center; }
.gcxa-root button { display: inline-flex; gap: 12px; align-items: center; border: 0; border-radius: 8px; min-height: 48px; padding: 0 24px; color: #fff; background: var(--gcxa-accent, #202421); cursor: pointer; transition: background .2s, transform .15s; }
.gcxa-root button:hover { background: #3b493f; }
.gcxa-root button:active { transform: translateY(1px); }
.gcxa-root button[data-state="success"] { background: #236340; }
.gcxa-root p { min-height: 18px; margin: 12px 0 0; font-size: 12px; }
.gcxa-icon { width: 18px; display: grid; place-items: center; }
.gcxa-spinner { width: 16px; height: 16px; border: 2px solid #ffffff65; border-top-color: #fff; border-radius: 50%; animation: gcxa-spin .7s linear infinite; }
@keyframes gcxa-spin { to { transform: rotate(360deg); } }`;

export function BotonAsincrono({
  label = "Guardar cambios",
  action,
  disabled,
  className = "",
  style,
}: BotonAsincronoProps) {
  const [state, setState] = useState<"idle" | "loading" | "success" | "error">(
    "idle",
  );
  const mounted = useRef(false);
  const busy = useRef(false);
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      if (timer.current) clearTimeout(timer.current);
    };
  }, []);
  async function run() {
    if (busy.current) return;
    busy.current = true;
    setState("loading");
    try {
      if (action) await action();
      else
        await new Promise<void>((resolve) => {
          timer.current = setTimeout(resolve, 900);
        });
      if (mounted.current) setState("success");
    } catch {
      if (mounted.current) setState("error");
    } finally {
      busy.current = false;
    }
  }
  return (
    <div className={`gcxa-root ${className}`} style={style}>
      <style>{CSS}</style>
      <button
        type="button"
        onClick={run}
        disabled={disabled || state === "loading"}
        aria-busy={state === "loading"}
        data-state={state}
      >
        <span className="gcxa-icon" aria-hidden="true">
          {state === "loading" ? (
            <span className="gcxa-spinner" />
          ) : (
            <svg
              viewBox="0 0 24 24"
              width="18"
              height="18"
              fill="none"
              stroke="currentColor"
              strokeWidth="1.8"
            >
              <path
                d={state === "success" ? "m5 12 4 4 10-10" : "M12 5v14m-7-7h14"}
              />
            </svg>
          )}
        </span>
        {state === "loading"
          ? "Guardando…"
          : state === "success"
            ? "Guardado · repetir"
            : state === "error"
              ? "Reintentar"
              : label}
      </button>
      <p role="status">
        {state === "error"
          ? "No se pudo guardar. Inténtalo de nuevo."
          : state === "success"
            ? "Cambios guardados."
            : ""}
      </p>
    </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/boton-asincrono

Ejemplo de uso de Botón de acción asíncrona

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

ejemplo.tsx5 líneas
import { BotonAsincrono } from "./boton-asincrono";

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

Propiedades de BotonAsincrono

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

PropiedadTipoDescripción
label?stringOpcional.
action?() => Promise<void>Opcional.
disabled?booleanOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Personaliza Botón de acción asíncrona con variables CSS

Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.

VariableValor por defecto
--gcxa-fg#202421
--gcxa-accent#25724b

Accesibilidad y rendimiento

El archivo tiene 117 líneas (4 KB) y define 1 animación con @keyframes. Esto es lo que hace Botón de acción asíncrona 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: status.
  • 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)