E-commerceecommerceshoptoastnotificationalertlive
Cart Toast
Notificaciones apiladas en vivo: «Añadido al carrito» con acción para ver el carrito y cualquier aviso propio enviado con el evento ack:notify.
Vista Previa en Vivo
Added to cart
Aurora Wireless Headphones · Graphite
Only 2 left in stock
Drift Everyday Sneakers · size 42
Uso
Copia y pega este código directamente en tu componente React:
tsx
import { CartToast } from 'react-component-kit-ui/ecommerce';
<CartToast position="bottom-right" />Instalación Manual
Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/CartToast.tsx + CartToast.css:
CartToast.tsx
'use client';
import { useEffect, useRef } from 'react';
import { ADDED_EVENT, NOTIFY_EVENT } from './cart-store';
import './CartToast.css';
/** class:list → className */
function cx(...args: unknown[]): string {
const out: string[] = [];
const walk = (v: unknown) => {
if (!v) return;
if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
else if (Array.isArray(v)) v.forEach(walk);
else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
};
args.forEach(walk);
return out.join(' ');
}
/**
* CartToast — Stacked live notifications for the shop: "Added to cart" (with a View cart
* action) whenever an add-to-cart button skips the drawer, plus any custom notice sent with
* `document.dispatchEvent(new CustomEvent('ack:notify', { detail: { title, text, tone } }))`.
*
* @param {string} cartKey - Only react to this cart. Default 'ack-cart'.
* @param {string} position - Screen corner (bottom-right|bottom-left|top-right|top-center). Default 'bottom-right'.
* @param {number} duration - Auto-dismiss delay in ms (paused on hover/focus). Default 4500.
* @param {number} max - Maximum toasts visible at once. Default 3.
* @param {string} addedTitle - Title for cart notices. Default 'Added to cart'.
* @param {string} actionLabel - Cart action label. Default 'View cart'.
* @param {boolean} always - Also toast when the drawer opens on add. Default false.
* @param {boolean} demo - Render one static example toast (docs/previews). Default false.
* @param {string} variant - Style (glass|solid). Default 'glass'.
* @param {string} className - Extra class.
*
* Accessibility: polite live region (role="status"), every toast has a close button,
* timers pause while hovered or focused, motion is removed with prefers-reduced-motion.
*/
export interface CartToastProps {
cartKey?: string;
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-center';
duration?: number;
max?: number;
addedTitle?: string;
actionLabel?: string;
always?: boolean;
demo?: boolean;
variant?: 'glass' | 'solid';
className?: string;
}
export function CartToast({
cartKey = 'ack-cart',
position = 'bottom-right',
duration = 4500,
max = 3,
addedTitle = 'Added to cart',
actionLabel = 'View cart',
always = false,
demo = false,
variant = 'glass',
className = '',
}: CartToastProps) {
// Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
const mounted = useRef(false);
useEffect(() => {
if (mounted.current) return;
mounted.current = true;
const ICONS: Record<string, string> = {
success: '<path d="M20 6 9 17l-5-5"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/>',
warning: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',
danger: '<circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/>',
};
document.querySelectorAll<HTMLElement>('[data-cart-toasts]:not([data-bound])').forEach((region) => {
region.dataset.bound = '';
if (region.classList.contains('cart-toasts--demo')) return;
const duration = Number(region.dataset.duration) || 4500;
const max = Number(region.dataset.max) || 3;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function show(opts: { title: string; text?: string; tone?: string; action?: { label: string; run: () => void } }) {
const tone = ICONS[opts.tone || ''] ? opts.tone! : 'info';
const toast = document.createElement('div');
toast.className = `cart-toast cart-toast--${tone}`;
toast.style.setProperty('--toast-ms', `${duration}ms`);
toast.innerHTML = `<span class="cart-toast__icon" aria-hidden="true"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">${ICONS[tone]}</svg></span>`;
const body = document.createElement('div');
body.className = 'cart-toast__body';
const title = document.createElement('p');
title.className = 'cart-toast__title';
title.textContent = opts.title;
body.append(title);
if (opts.text) {
const text = document.createElement('p');
text.className = 'cart-toast__text';
text.textContent = opts.text;
body.append(text);
}
toast.append(body);
if (opts.action) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'cart-toast__action';
btn.textContent = opts.action.label;
btn.addEventListener('click', () => { opts.action!.run(); dismiss(); });
toast.append(btn);
}
const close = document.createElement('button');
close.type = 'button';
close.className = 'cart-toast__close';
close.setAttribute('aria-label', 'Dismiss notification');
close.textContent = '×';
close.addEventListener('click', () => dismiss());
const bar = document.createElement('span');
bar.className = 'cart-toast__timer';
bar.setAttribute('aria-hidden', 'true');
toast.append(close, bar);
let left = duration;
let started = Date.now();
let timer = window.setTimeout(() => dismiss(), left);
const pause = () => { window.clearTimeout(timer); left -= Date.now() - started; toast.classList.add('is-paused'); };
const resume = () => { started = Date.now(); timer = window.setTimeout(() => dismiss(), Math.max(800, left)); toast.classList.remove('is-paused'); };
toast.addEventListener('mouseenter', pause);
toast.addEventListener('mouseleave', resume);
toast.addEventListener('focusin', pause);
toast.addEventListener('focusout', resume);
function dismiss() {
window.clearTimeout(timer);
if (!toast.isConnected) return;
toast.classList.add('is-leaving');
window.setTimeout(() => toast.remove(), reduced ? 0 : 220);
}
region.append(toast);
while (region.children.length > max) region.firstElementChild?.remove();
}
document.addEventListener(ADDED_EVENT, (e) => {
const d = (e as CustomEvent).detail || {};
if (d.key && d.key !== region.dataset.key) return;
if (d.openCart && !region.hasAttribute('data-always')) return;
const item = d.item || {};
show({
title: region.dataset.title || 'Added to cart',
text: [item.name, item.variant].filter(Boolean).join(' · ') + (item.qty > 1 ? ` × ${item.qty}` : ''),
tone: 'success',
action: {
label: region.dataset.action || 'View cart',
run: () => document.dispatchEvent(new CustomEvent('ack:cart-open', { detail: { key: region.dataset.key } })),
},
});
});
document.addEventListener(NOTIFY_EVENT, (e) => {
const d = (e as CustomEvent).detail || {};
if (d.title) show({ title: d.title, text: d.text, tone: d.tone });
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className={cx(['cart-toasts', `cart-toasts--${position}`, `cart-toasts--${variant}`, { 'cart-toasts--demo': demo }, className])} role="status" aria-live="polite" data-cart-toasts data-key={cartKey} data-duration={duration} data-max={max} data-title={addedTitle} data-action={actionLabel} data-always={always ? '' : undefined} data-rk-da674f="">
{demo && (<>
<div className="cart-toast cart-toast--success" data-rk-da674f="">
<span className="cart-toast__icon" aria-hidden="true" data-rk-da674f=""><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" data-rk-da674f=""><path d="M20 6 9 17l-5-5" data-rk-da674f="" /></svg></span>
<div className="cart-toast__body" data-rk-da674f=""><p className="cart-toast__title" data-rk-da674f="">{addedTitle}</p><p className="cart-toast__text" data-rk-da674f="">Aurora Wireless Headphones · Graphite</p></div>
<button className="cart-toast__action" type="button" data-rk-da674f="">{actionLabel}</button>
<button className="cart-toast__close" type="button" aria-label="Dismiss" data-rk-da674f="">×</button>
<span className="cart-toast__timer" aria-hidden="true" data-rk-da674f=""></span>
</div>
<div className="cart-toast cart-toast--warning" data-rk-da674f="">
<span className="cart-toast__icon" aria-hidden="true" data-rk-da674f=""><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" data-rk-da674f=""><path d="M12 9v4M12 17h.01" data-rk-da674f="" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" data-rk-da674f="" /></svg></span>
<div className="cart-toast__body" data-rk-da674f=""><p className="cart-toast__title" data-rk-da674f="">Only 2 left in stock</p><p className="cart-toast__text" data-rk-da674f="">Drift Everyday Sneakers · size 42</p></div>
<button className="cart-toast__close" type="button" aria-label="Dismiss" data-rk-da674f="">×</button>
</div>
</>)}
</div>
</>
);
}
export default CartToast;CartToast.css
/* CartToast — ported from astro-component-kit (ecommerce/CartToast.astro) */
.cart-toasts:where([data-rk-da674f]) {
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
gap: var(--sp-2, 0.5rem);
width: min(380px, calc(100vw - 2rem));
pointer-events: none;
}
.cart-toasts--bottom-right:where([data-rk-da674f]) { right: 1rem; bottom: 1rem; }
.cart-toasts--bottom-left:where([data-rk-da674f]) { left: 1rem; bottom: 1rem; }
.cart-toasts--top-right:where([data-rk-da674f]) { right: 1rem; top: 1rem; flex-direction: column-reverse; }
.cart-toasts--top-center:where([data-rk-da674f]) { left: 50%; top: 1rem; transform: translateX(-50%); flex-direction: column-reverse; }
.cart-toasts--demo:where([data-rk-da674f]) { position: relative; inset: auto; transform: none; margin: 0 auto; }
.cart-toasts:where([data-rk-da674f]) .cart-toast {
--tone: var(--c-primary-light, #d4a017);
position: relative;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: var(--sp-3, 0.75rem);
padding: var(--sp-3, 0.75rem) var(--sp-3, 0.75rem) var(--sp-3, 0.75rem) var(--sp-4, 1rem);
border-radius: var(--r-md, 12px);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
background: rgb(var(--paper-rgb, 5 5 5) / 0.86);
backdrop-filter: blur(16px) saturate(1.4);
box-shadow: 0 20px 50px -20px rgba(0, 0, 0, 0.6);
color: var(--c-text-1, #fff);
overflow: hidden;
pointer-events: auto;
animation: cart-toast-ct-in 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
.cart-toasts--solid:where([data-rk-da674f]) .cart-toast { background: var(--c-bg-1, #0a0a0a); backdrop-filter: none; }
.cart-toasts:where([data-rk-da674f]) .cart-toast--success { --tone: var(--c-success, #34d399); }
.cart-toasts:where([data-rk-da674f]) .cart-toast--warning { --tone: var(--c-warning, #fbbf24); }
.cart-toasts:where([data-rk-da674f]) .cart-toast--danger { --tone: var(--c-danger, #f87171); }
.cart-toasts:where([data-rk-da674f]) .cart-toast--info { --tone: var(--c-primary-light, #d4a017); }
.cart-toasts:where([data-rk-da674f]) .cart-toast.is-leaving { animation: cart-toast-ct-out 0.22s ease forwards; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__icon {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
color: var(--tone);
background: color-mix(in srgb, var(--tone) 15%, transparent);
}
.cart-toasts:where([data-rk-da674f]) .cart-toast__body { min-width: 0; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__title { margin: 0; font-size: 0.88rem; font-weight: 700; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__text { margin: 2px 0 0; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__action {
min-height: 36px;
padding: 0 var(--sp-3, 0.75rem);
border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.16);
border-radius: var(--r-full, 9999px);
background: transparent;
color: var(--c-text-1, #fff);
font: inherit;
font-size: 0.8rem;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.cart-toasts:where([data-rk-da674f]) .cart-toast__action:hover { background: rgb(var(--ink-rgb, 255 255 255) / 0.07); }
.cart-toasts:where([data-rk-da674f]) .cart-toast__close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--c-text-2, #a3a3a3);
font-size: 1.3rem;
line-height: 1;
cursor: pointer;
}
.cart-toasts:where([data-rk-da674f]) .cart-toast__close:hover { color: var(--c-text-1, #fff); background: rgb(var(--ink-rgb, 255 255 255) / 0.06); }
.cart-toasts:where([data-rk-da674f]) .cart-toast button:focus-visible { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__timer {
position: absolute;
left: 0;
bottom: 0;
height: 2px;
width: 100%;
background: var(--tone);
transform-origin: left;
animation: cart-toast-ct-timer var(--toast-ms, 4500ms) linear forwards;
}
.cart-toasts:where([data-rk-da674f]) .cart-toast.is-paused .cart-toast__timer { animation-play-state: paused; }
.cart-toasts--demo:where([data-rk-da674f]) .cart-toast__timer { animation: none; transform: scaleX(0.6); }
@keyframes cart-toast-ct-in { from { opacity: 0; transform: translateY(12px) scale(0.97); } }
@keyframes cart-toast-ct-out { to { opacity: 0; transform: translateX(24px); } }
@keyframes cart-toast-ct-timer { to { transform: scaleX(0); } }
@media (prefers-reduced-motion: reduce) {
.cart-toasts:where([data-rk-da674f]) .cart-toast, .cart-toasts:where([data-rk-da674f]) .cart-toast.is-leaving { animation: none; }
.cart-toasts:where([data-rk-da674f]) .cart-toast__timer { display: none; }
}Información Rápida
- Categoría
- E-commerce
- Nombre de archivo
CartToast.tsx- Dependencias
- Ninguna — React + CSS puro
- Etiquetas
- ecommerceshoptoastnotificationalertlive