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.
/**
* 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.
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-morphEjemplo 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.
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.
| Propiedad | Tipo | Descripción |
|---|---|---|
label? | string | Opcional. |
successLabel? | string | Texto que anuncian los lectores de pantalla al confirmar. |
onDone? | () => void | Se ejecuta cuando el botón termina de volver a su forma. |
href? | string | Opcional. |
tone? | "blue" | "ink" | "green" | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
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.
| Variable | Valor por defecto |
|---|---|
--gcbm-w | auto |
--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".
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