Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

Botón que confirma

Botón que se encoge a un círculo con un check cuando la acción se completa.

Código completo

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

boton-morph.tsx106 líneas
Descargar
/**
 * Botón que confirma una acción transformándose: al pulsarlo el rectángulo se encoge hasta
 * un círculo, dibuja un check y lanza un anillo; tras 1.6 s vuelve a su forma y ejecuta
 * `onDone`. El ancho se mide al pulsar, así que funciona con cualquier etiqueta. Con `href`
 * se comporta como un enlace normal. Los colores se sobrescriben con --gcbm-tone-bg y --gcbm-tone-fg.
 *
 *   <BotonMorph label="Enviar" successLabel="Enviado" onDone={() => save()} />
 */

"use client";

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

export type BotonMorphProps = {
  label?: string;
  /** Texto que anuncian los lectores de pantalla al confirmar. */
  successLabel?: string;
  /** Se ejecuta cuando el botón termina de volver a su forma. */
  onDone?: () => void;
  href?: string;
  tone?: "blue" | "ink" | "green";
  className?: string;
  style?: CSSProperties;
};

const TONES: Record<string, { bg: string; fg: string }> = {
  blue: { bg: "#0b6fe0", fg: "#ffffff" },
  ink: { bg: "#f2f2f3", fg: "#0a0a0b" },
  green: { bg: "#1a8f55", fg: "#ffffff" },
};

const CSS = `
.gcbm-root { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: var(--gcbm-w, auto); height: 48px; padding: 0 26px; border: 0; border-radius: 10px; background: var(--gcbm-bg, #0b6fe0); color: var(--gcbm-fg, #fff); font: 600 15px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: -.005em; text-decoration: none; white-space: nowrap; cursor: pointer; transition: width .55s cubic-bezier(.16, 1, .3, 1), padding .55s cubic-bezier(.16, 1, .3, 1), border-radius .55s cubic-bezier(.16, 1, .3, 1), filter .2s, transform .15s; }
.gcbm-root:hover { filter: brightness(1.1); }
.gcbm-root:active { transform: scale(.97); }
.gcbm-root:focus-visible { outline: 2px solid var(--gcbm-bg, #0b6fe0); outline-offset: 3px; }
.gcbm-root[data-state="success"] { width: 48px; padding: 0; border-radius: 24px; cursor: default; filter: none; }
.gcbm-label { transition: opacity .2s, transform .45s cubic-bezier(.16, 1, .3, 1); }
.gcbm-root[data-state="success"] .gcbm-label { opacity: 0; transform: translateY(-8px); }
.gcbm-check { position: absolute; inset: 0; display: grid; place-items: center; }
.gcbm-check svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 26; stroke-dashoffset: 26; }
.gcbm-root[data-state="success"] .gcbm-check svg { animation: gcbm-draw .45s cubic-bezier(.16, 1, .3, 1) .2s forwards; }
.gcbm-root::after { content: ""; position: absolute; inset: 0; border-radius: inherit; border: 2px solid var(--gcbm-bg, #0b6fe0); opacity: 0; pointer-events: none; }
.gcbm-root[data-state="success"]::after { animation: gcbm-ring .7s cubic-bezier(.16, 1, .3, 1) .25s; }
.gcbm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@keyframes gcbm-draw { to { stroke-dashoffset: 0; } }
@keyframes gcbm-ring { 0% { opacity: .7; transform: scale(1); } 100% { opacity: 0; transform: scale(1.7); } }
@media (prefers-reduced-motion: reduce) { .gcbm-root, .gcbm-label { transition: none; } .gcbm-root[data-state="success"] .gcbm-check svg { animation: none; stroke-dashoffset: 0; } .gcbm-root[data-state="success"]::after { animation: none; } }
`;

export function BotonMorph({
  label = "Enviar",
  successLabel = "Enviado",
  onDone,
  href,
  tone = "blue",
  className,
  style,
}: BotonMorphProps) {
  const t = TONES[tone] ?? TONES.blue;
  const ref = useRef<HTMLButtonElement & HTMLAnchorElement>(null);
  const [state, setState] = useState<"idle" | "success">("idle");
  const timers = useRef<ReturnType<typeof setTimeout>[]>([]);

  useEffect(() => () => timers.current.forEach(clearTimeout), []);

  const handle = (event: MouseEvent<HTMLElement>) => {
    if (href) return;
    event.preventDefault();
    const el = ref.current;
    if (!el || state !== "idle") return;
    // Fija el ancho actual y fuerza un reflujo para que el cambio a círculo se anime de px a px.
    el.style.setProperty("--gcbm-w", `${el.offsetWidth}px`);
    void el.offsetWidth;
    setState("success");
    timers.current.push(
      setTimeout(() => {
        setState("idle");
        onDone?.();
      }, 1600),
      setTimeout(() => el.style.removeProperty("--gcbm-w"), 2300),
    );
  };

  const cssVars = { ...style, ["--gcbm-bg" as string]: `var(--gcbm-tone-bg, ${t.bg})`, ["--gcbm-fg" as string]: `var(--gcbm-tone-fg, ${t.fg})` };
  const cls = ["gcbm-root", className].filter(Boolean).join(" ");
  const inner = (
    <>
      <span className="gcbm-label">{label}</span>
      <span className="gcbm-check" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5.5 12.5l4.5 4.5 8.5-10" /></svg></span>
    </>
  );

  return (
    <>
      <style>{CSS}</style>
      {href ? (
        <a ref={ref} href={href} className={cls} style={cssVars}>{inner}</a>
      ) : (
        <button ref={ref} type="button" className={cls} style={cssVars} data-state={state} aria-disabled={state === "success"} onClick={handle}>{inner}</button>
      )}
      <span className="gcbm-sr" role="status">{state === "success" ? successLabel : ""}</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-morph

Ejemplo de uso de Botón que confirma

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

ejemplo.tsx5 líneas
import { BotonMorph } from "./boton-morph";

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

Propiedades de BotonMorph

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

PropiedadTipoDescripción
label?stringOpcional.
successLabel?stringTexto que anuncian los lectores de pantalla al confirmar.
onDone?() => voidSe ejecuta cuando el botón termina de volver a su forma.
href?stringOpcional.
tone?"blue" | "ink" | "green"Opcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Variantes disponibles

  • tone: blue, ink, green

Personaliza Botón que confirma 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
--gcbm-wauto
--gcbm-bg#0b6fe0
--gcbm-fg#fff
--gcbm-tone-bg${t.bg}
--gcbm-tone-fg${t.fg}

Accesibilidad y rendimiento

El archivo tiene 106 líneas (5 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Botón que confirma 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

Acerca del producto

Mide su ancho al pulsarlo, se reduce a círculo, dibuja un check y lanza un anillo; después vuelve a su forma y ejecuta onDone. Anuncia el éxito a lectores de pantalla.

Incluye

Un solo archivo React + TypeScript con estilos dentro. Sin Tailwind ni dependencias externas.

  • Funciona con cualquier etiqueta
  • Check que se dibuja
  • Anuncio accesible del éxito
  • Tres tonos y variables de color
  • Con href se comporta como enlace