Ir al contenido
Evaluar mi proyecto
Componentes · BotonesDisponible

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.

boton-material.tsx88 líneas
Descargar
"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.

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

Ejemplo 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.

ejemplo.tsx5 líneas
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.

PropiedadTipoDescripción
children?ReactNodeOpcional.
variant?BotonMaterialVarianteOpcional.
icon?ReactNodeIcono a la izquierda del texto (o único contenido en `fab`).
type?"button" | "submit"Opcional.
disabled?booleanOpcional.
onClick?() => voidOpcional.
ariaLabel?stringOpcional.
className?stringOpcional.
style?CSSPropertiesOpcional.

Variantes disponibles

  • variant: filled, tonal, outlined, text, elevated, fab, extended
  • type: 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.

VariableValor 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".
Licencia
MIT
Dependencias
React >=18
Compatibilidad
React 18 y 19 · Next.js (componente de cliente)