Botones Material Design 3
Los siete botones de Material 3 (filled, tonal, outlined, text, elevated, FAB y extendido) con capas de estado y ondulación desde el toque.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, type CSSProperties, type PointerEvent, type ReactNode } from "react";
/** Botón Material Design 3 con capas de estado y ondulación desde el punto de contacto. React + TypeScript, sin dependencias. Personaliza con --gcmat-*. */
export type BotonMaterialVariante = "filled" | "tonal" | "outlined" | "text" | "elevated" | "fab" | "extended";
export type BotonMaterialProps = {
children?: ReactNode;
variant?: BotonMaterialVariante;
/** Icono a la izquierda del texto (o único contenido en `fab`). */
icon?: ReactNode;
type?: "button" | "submit";
disabled?: boolean;
onClick?: () => void;
ariaLabel?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gcmat-root { box-sizing: border-box; display: inline-block; max-width: 100%; font: 500 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .1px; }
.gcmat-root *, .gcmat-root *::before, .gcmat-root *::after { box-sizing: border-box; }
.gcmat-root button { font: inherit; letter-spacing: inherit; -webkit-tap-highlight-color: transparent; }
.gcmat-root :focus-visible { outline: 3px solid var(--gcmat-focus, #1d1b20); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gcmat-root *, .gcmat-root *::before, .gcmat-root *::after { animation: none !important; transition-duration: .01ms !important; } }
.gcmat-btn { --gcmat-fg: var(--gcmat-on-primary, #fff); --gcmat-bg: var(--gcmat-primary, #6750a4); position: relative; overflow: hidden; isolation: isolate; display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 40px; padding: 0 24px; border: 0; border-radius: 20px; background: var(--gcmat-bg); color: var(--gcmat-fg); cursor: pointer; user-select: none; transition: box-shadow .2s cubic-bezier(.2, 0, 0, 1), border-radius .2s cubic-bezier(.2, 0, 0, 1); }
.gcmat-btn[data-v="tonal"] { --gcmat-bg: var(--gcmat-container, #e8def8); --gcmat-fg: var(--gcmat-on-container, #1d192b); }
.gcmat-btn[data-v="outlined"] { --gcmat-bg: transparent; --gcmat-fg: var(--gcmat-primary, #6750a4); box-shadow: inset 0 0 0 1px var(--gcmat-outline, #79747e); }
.gcmat-btn[data-v="text"] { --gcmat-bg: transparent; --gcmat-fg: var(--gcmat-primary, #6750a4); padding: 0 12px; }
.gcmat-btn[data-v="elevated"] { --gcmat-bg: var(--gcmat-surface, #f7f2fa); --gcmat-fg: var(--gcmat-primary, #6750a4); box-shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 1px 3px 1px rgba(0, 0, 0, .15); }
.gcmat-btn[data-v="fab"], .gcmat-btn[data-v="extended"] { --gcmat-bg: var(--gcmat-container, #eaddff); --gcmat-fg: var(--gcmat-on-container, #21005d); height: 56px; border-radius: 16px; box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 1px 2px rgba(0, 0, 0, .3); }
.gcmat-btn[data-v="fab"] { width: 56px; padding: 0; }
.gcmat-btn[data-v="extended"] { padding: 0 20px 0 16px; gap: 12px; font-size: 14px; }
.gcmat-btn[data-icon="true"]:not([data-v="fab"]):not([data-v="extended"]) { padding-left: 16px; }
.gcmat-btn::after { content: ""; position: absolute; inset: 0; background: currentColor; opacity: 0; transition: opacity .15s; pointer-events: none; }
.gcmat-btn:hover:not(:disabled)::after { opacity: .08; }
.gcmat-btn:focus-visible::after { opacity: .1; }
.gcmat-btn:active:not(:disabled)::after { opacity: .1; }
.gcmat-btn:hover:not(:disabled):is([data-v="filled"], [data-v="elevated"], [data-v="fab"], [data-v="extended"]) { box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 2px 6px 2px rgba(0, 0, 0, .15); }
.gcmat-btn:active:not(:disabled) { border-radius: 12px; }
.gcmat-btn[data-v="fab"]:active:not(:disabled), .gcmat-btn[data-v="extended"]:active:not(:disabled) { border-radius: 12px; }
.gcmat-btn:disabled { cursor: not-allowed; background: color-mix(in srgb, #1d1b20 12%, transparent); color: color-mix(in srgb, #1d1b20 38%, transparent); box-shadow: none; }
.gcmat-btn[data-v="outlined"]:disabled { background: transparent; box-shadow: inset 0 0 0 1px color-mix(in srgb, #1d1b20 12%, transparent); }
.gcmat-btn[data-v="text"]:disabled { background: transparent; }
.gcmat-ripple { position: absolute; z-index: -1; border-radius: 50%; background: currentColor; opacity: .16; pointer-events: none; transform: scale(0); animation: gcmat-ripple .6s cubic-bezier(.2, 0, 0, 1) forwards; }
.gcmat-icon { display: inline-flex; width: 18px; height: 18px; }
.gcmat-btn[data-v="fab"] .gcmat-icon, .gcmat-btn[data-v="extended"] .gcmat-icon { width: 24px; height: 24px; }
.gcmat-icon svg { width: 100%; height: 100%; }
@keyframes gcmat-ripple { to { transform: scale(1); opacity: 0; } }`;
const PlusIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
);
type Ripple = { id: number; x: number; y: number; size: number };
export function BotonMaterial({ children, variant = "filled", icon, type = "button", disabled, onClick, ariaLabel, className = "", style }: BotonMaterialProps) {
const [ripples, setRipples] = useState<Ripple[]>([]);
const isFab = variant === "fab" || variant === "extended";
const glyph = icon ?? (isFab ? PlusIcon : null);
const label = children ?? (variant === "extended" ? "Nuevo" : variant === "fab" ? null : "Guardar");
function spawn(event: PointerEvent<HTMLButtonElement>) {
if (disabled) return;
const rect = event.currentTarget.getBoundingClientRect();
const size = Math.hypot(Math.max(event.clientX - rect.left, rect.right - event.clientX), Math.max(event.clientY - rect.top, rect.bottom - event.clientY)) * 2;
const id = Date.now() + Math.random();
setRipples((list) => [...list.slice(-3), { id, x: event.clientX - rect.left - size / 2, y: event.clientY - rect.top - size / 2, size }]);
window.setTimeout(() => setRipples((list) => list.filter((item) => item.id !== id)), 650);
}
return (
<span className={`gcmat-root ${className}`} style={style}>
<style>{CSS}</style>
<button className="gcmat-btn" type={type} data-v={variant} data-icon={glyph ? "true" : undefined} disabled={disabled} aria-label={ariaLabel ?? (variant === "fab" ? "Crear" : undefined)} onPointerDown={spawn} onClick={onClick}>
{ripples.map((ripple) => (
<span key={ripple.id} className="gcmat-ripple" style={{ left: ripple.x, top: ripple.y, width: ripple.size, height: ripple.size }} />
))}
{glyph ? <span className="gcmat-icon">{glyph}</span> : null}
{label}
</button>
</span>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/boton-materialEjemplo de uso de Botones Material Design 3
Después de copiar boton-material.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { BotonMaterial } from "./boton-material";
export default function Ejemplo() {
return <BotonMaterial variant="filled" ariaLabel="Ejemplo">Contenido</BotonMaterial>;
}Propiedades de BotonMaterial
BotonMaterial acepta 9 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
children? | ReactNode | Opcional. |
variant? | BotonMaterialVariante | Opcional. |
icon? | ReactNode | Icono a la izquierda del texto (o único contenido en `fab`). |
type? | "button" | "submit" | Opcional. |
disabled? | boolean | Opcional. |
onClick? | () => void | Opcional. |
ariaLabel? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Variantes disponibles
variant:filled,tonal,outlined,text,elevated,fab,extendedtype:button,submit
Personaliza Botones Material Design 3 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 |
|---|---|
--gcmat-focus | #1d1b20 |
--gcmat-on-primary | #fff |
--gcmat-primary | #6750a4 |
--gcmat-container | #e8def8 |
--gcmat-on-container | #1d192b |
--gcmat-outline | #79747e |
--gcmat-surface | #f7f2fa |
Accesibilidad y rendimiento
El archivo tiene 88 líneas (6 KB) y define 1 animación con @keyframes. Esto es lo que hace Botones Material Design 3 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).
- Expone su estado a tecnologías de apoyo con atributos aria-*.
- Calcula tonos con color-mix(), así que un solo color base alcanza.
- Es un componente de cliente de Next.js: incluye la directiva "use client".