Snackbar Material Design 3
Aviso breve con acción Deshacer; el cierre automático se pausa al apuntar o enfocar y se anuncia por región viva.
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
/** Snackbar Material Design 3: aviso breve con acción "Deshacer", cierre automático que se pausa al apuntar o enfocar, y anuncio por región viva. React + TypeScript, sin dependencias. */
export type CapaSnackbarProps = {
trigger?: string;
message?: string;
action?: string;
duration?: number;
onAction?: () => void;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gccts-root { box-sizing: border-box; display: grid; justify-items: center; gap: 28px; width: 100%; max-width: 480px; color: #1d1b20; font: 400 14px/20px Roboto, "Google Sans", ui-sans-serif, system-ui, sans-serif; letter-spacing: .25px; }
.gccts-root *, .gccts-root *::before, .gccts-root *::after { box-sizing: border-box; }
.gccts-root button { font: inherit; letter-spacing: inherit; }
.gccts-root :focus-visible { outline: 3px solid #d0bcff; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .gccts-bar { animation: none !important; } .gccts-snack { animation: none !important; } }
.gccts-open { height: 40px; padding: 0 24px; border: 0; border-radius: 20px; background: #6750a4; color: #fff; font-weight: 500; letter-spacing: .1px; cursor: pointer; transition: box-shadow .2s; }
.gccts-open:hover { box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 1px 2px rgba(0, 0, 0, .3); }
.gccts-slot { position: relative; width: 100%; height: 72px; }
.gccts-snack { position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 8px; min-height: 48px; padding: 0 8px 0 16px; border-radius: 4px; background: #322f35; color: #f5eff7; box-shadow: 0 1px 3px 1px rgba(0, 0, 0, .15), 0 1px 2px rgba(0, 0, 0, .3); overflow: hidden; animation: gccts-in .3s cubic-bezier(.2, 0, 0, 1); }
.gccts-msg { flex: 1; padding: 14px 0; }
.gccts-act { height: 40px; padding: 0 12px; border: 0; border-radius: 20px; background: none; color: #d0bcff; font-weight: 500; letter-spacing: .1px; cursor: pointer; }
.gccts-act:hover { background: rgba(208, 188, 255, .12); }
.gccts-x { display: grid; place-items: center; width: 40px; height: 40px; border: 0; border-radius: 50%; background: none; color: #e6e0e9; cursor: pointer; }
.gccts-x:hover { background: rgba(230, 224, 233, .12); }
.gccts-bar { position: absolute; left: 0; bottom: 0; height: 3px; width: 100%; background: #d0bcff; transform-origin: left; animation: gccts-run var(--d) linear forwards; animation-play-state: var(--ps, running); }
@keyframes gccts-in { from { opacity: 0; transform: translateY(16px) scale(.98); } }
@keyframes gccts-run { from { transform: scaleX(1); } to { transform: scaleX(0); } }`;
export function CapaSnackbar({ trigger = "Archivar conversación", message = "Conversación archivada", action = "Deshacer", duration = 6000, onAction, className = "", style }: CapaSnackbarProps) {
const [show, setShow] = useState(false);
const [paused, setPaused] = useState(false);
const [key, setKey] = useState(0);
const timer = useRef(0);
const started = useRef(0);
const left = useRef(duration);
function clear() { window.clearTimeout(timer.current); }
function arm(ms: number) {
clear();
started.current = Date.now();
left.current = ms;
timer.current = window.setTimeout(() => setShow(false), ms);
}
useEffect(() => clear, []);
useEffect(() => {
if (!show) return;
const esc = (event: KeyboardEvent) => event.key === "Escape" && setShow(false);
document.addEventListener("keydown", esc);
return () => document.removeEventListener("keydown", esc);
}, [show]);
function open() {
setPaused(false);
setKey((value) => value + 1);
setShow(true);
arm(duration);
}
function pause() { if (!show) return; setPaused(true); left.current -= Date.now() - started.current; clear(); }
function resume() { if (!show) return; setPaused(false); arm(Math.max(1200, left.current)); }
return (
<div className={`gccts-root ${className}`} style={style}>
<style>{CSS}</style>
<button className="gccts-open" type="button" onClick={open}>{trigger}</button>
<div className="gccts-slot" role="status" aria-live="polite">
{show ? (
<div key={key} className="gccts-snack" onPointerEnter={pause} onPointerLeave={resume} onFocus={pause} onBlur={resume}>
<span className="gccts-msg">{message}</span>
<button className="gccts-act" type="button" onClick={() => { onAction?.(); setShow(false); }}>{action}</button>
<button className="gccts-x" type="button" aria-label="Cerrar aviso" onClick={() => setShow(false)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18" /></svg>
</button>
<span className="gccts-bar" style={{ "--d": `${duration}ms`, "--ps": paused ? "paused" : "running" } as CSSProperties} />
</div>
) : null}
</div>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/capa-snackbarEjemplo de uso de Snackbar Material Design 3
Después de copiar capa-snackbar.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { CapaSnackbar } from "./capa-snackbar";
export default function Ejemplo() {
return <CapaSnackbar trigger="Ejemplo" />;
}Propiedades de CapaSnackbar
CapaSnackbar acepta 7 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
trigger? | string | Opcional. |
message? | string | Opcional. |
action? | string | Opcional. |
duration? | number | Opcional. |
onAction? | () => void | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Snackbar 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 |
|---|---|
--ps | running |
Accesibilidad y rendimiento
El archivo tiene 86 líneas (5 KB) y define 2 animaciones con @keyframes. Esto es lo que hace Snackbar 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).
- Declara roles ARIA para lectores de pantalla: status.
- Responde al teclado, no solo al ratón.
- Es un componente de cliente de Next.js: incluye la directiva "use client".