# Toast / Aviso (toast)

> Short message that stacks and spreads on hover.

Page: https://componentes-lovat.vercel.app/c/toast/  
License: MIT · Design: coss ui (MIT) · toast.tsx  
Requires: tokens.css, button  
Context: ambos · Density: media · Tone: sobria

## Files

- Tokens (once): https://componentes-lovat.vercel.app/tokens.css
- Dependency `button`: https://componentes-lovat.vercel.app/r/button.json
- HTML: https://componentes-lovat.vercel.app/raw/toast/toast.html
- CSS: https://componentes-lovat.vercel.app/raw/toast/toast.css
- JS: https://componentes-lovat.vercel.app/raw/toast/toast.js
- Everything in one file: https://componentes-lovat.vercel.app/r/toast.html

Dark theme: add class `dark` to `<html>`.

## When to use

- To confirm something that already happened.

## When not to use

- For form errors: place them next to the field.

## Accessibility

Roles: status

- The role=status region exists from page load.
- At most 3 visible, 5 s each; pauses on hover, focus or hidden tab.

## Code

```html
<div class="c-btn-row">
  <button class="c-btn c-btn--outline" type="button" data-c-toast="Cambios guardados" data-c-toast-desc="Tu perfil está al día." data-c-toast-type="success">Guardar</button>
  <button class="c-btn c-btn--outline" type="button" data-c-toast="Enlace copiado" data-c-toast-type="info">Copiar enlace</button>
  <button class="c-btn c-btn--outline" type="button" data-c-toast="No se pudo exportar" data-c-toast-desc="Prueba con un formato más pequeño." data-c-toast-type="error">Simular error</button>
</div>

<!-- Una vez por página, al final del <body>. Debe existir desde que carga. -->
<section aria-label="Notificaciones">
  <ol class="c-toast-viewport" data-position="bottom-right" role="status" aria-live="polite" aria-relevant="additions"></ol>
</section>
```

```css
/* toast · Componentes (MIRÓ Labs). Diseño de coss ui (MIT, © coss.com, antes Origin UI).
   Generado por tools/build_css.py desde toast.tw.json. No editar a mano. Necesita tokens.css. */
.c-toast__content{pointer-events:auto}
.c-toast{position:absolute}
.c-toast-viewport{position:fixed;z-index:60}
.c-toast{z-index:calc(9999 - var(--toast-index))}
.c-toast-viewport{margin-inline:auto;display:flex}
.c-toast__content{display:flex}
.c-toast__main{display:flex}
.c-toast__text{display:flex}
.c-toast{height:var(--toast-calc-height)}
.c-toast-viewport{width:calc(100% - var(--toast-inset)*2)}
.c-toast{width:100%}
.c-toast-viewport{max-width:calc(var(--spacing)*90)}
.c-toast__text{flex-direction:column}
.c-toast__content{align-items:center;justify-content:space-between}
.c-toast__text{gap:calc(var(--spacing)*.5)}
.c-toast__content{gap:calc(var(--spacing)*1.5)}
.c-toast__main{gap:calc(var(--spacing)*2)}
.c-toast__content{overflow:hidden}
.c-toast{border-radius:var(--radius);border-style:var(--tw-border-style);border-width:1px;background-color:var(--popover)}
@supports (color:color-mix(in lab, red, red)){
.c-toast{background-color:color-mix(in srgb,var(--popover),var(--color-black)calc(1%*max(0,var(--toast-index,0))))}
}
.c-toast__content{padding-inline:calc(var(--spacing)*3.5);padding-block:calc(var(--spacing)*3);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}
.c-toast__title{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}
.c-toast__description{color:var(--muted-foreground)}
.c-toast{color:var(--popover-foreground);--tw-shadow-alpha:5%;--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000000d),0 4px 6px -4px var(--tw-shadow-color,#0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
@supports (color:lab(0% 0 0)){
.c-toast{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,lab(0% 0 0/.05)),0 4px 6px -4px var(--tw-shadow-color,lab(0% 0 0/.05))}
}
.c-toast__content{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));--tw-duration:.25s;transition-duration:.25s}
.c-toast{-webkit-user-select:none;user-select:none;--toast-calc-height:var(--toast-frontmost-height,var(--toast-height));--toast-gap:calc(var(--spacing)*3)}
.c-toast-viewport{--toast-inset:calc(var(--spacing)*4)}
.c-toast{--toast-peek:calc(var(--spacing)*3);--toast-scale:calc(max(0,1 - (var(--toast-index)*.1)));--toast-shrink:calc(1 - var(--toast-scale));transition:transform .5s cubic-bezier(.22,1,.36,1),opacity .5s,height .15s,background-color .5s}
.c-toast:not(:is(.dark *)){background-clip:padding-box}
.c-toast:before{content:var(--tw-content);pointer-events:none;content:var(--tw-content);position:absolute;content:var(--tw-content);inset:calc(var(--spacing)*0);content:var(--tw-content);border-radius:calc(var(--radius-lg) - 1px);content:var(--tw-content);--tw-shadow:0 1px var(--tw-shadow-color,#0000000a)}
@supports (color:color-mix(in lab, red, red)){
.c-toast:before{--tw-shadow:0 1px var(--tw-shadow-color,color-mix(in oklab,var(--color-black)4%,transparent))}
}
.c-toast:before{box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
.c-toast:after{content:var(--tw-content);position:absolute;content:var(--tw-content);left:calc(var(--spacing)*0);content:var(--tw-content);height:calc(var(--toast-gap) + 1px);content:var(--tw-content);width:100%}
.c-toast__content[data-behind]{opacity:0}
.c-toast__content[data-behind]:not([data-expanded]){pointer-events:none}
.c-toast[data-expanded]{background-color:var(--popover)}
.c-toast__content[data-expanded]{opacity:1}
.c-toast[data-position][data-expanded]{transform:translateX(var(--toast-swipe-movement-x))translateY(var(--toast-calc-offset-y))}
.c-toast[data-position*=bottom]{top:auto}
.c-toast-viewport[data-position*=bottom]{bottom:var(--toast-inset)}
.c-toast[data-position*=bottom]{bottom:calc(var(--spacing)*0);transform-origin:50% calc(50% + 50%*min(var(--toast-index,0),1));transform:translateX(var(--toast-swipe-movement-x))translateY(calc(var(--toast-swipe-movement-y) - (var(--toast-index)*var(--toast-peek)) - (var(--toast-shrink)*var(--toast-calc-height))))scale(var(--toast-scale));--toast-calc-offset-y:calc(var(--toast-offset-y)*-1 + var(--toast-index)*var(--toast-gap)*-1 + var(--toast-swipe-movement-y))}
.c-toast[data-position*=bottom]:after{content:var(--tw-content);bottom:100%}
.c-toast[data-position*=bottom][data-starting-style]{transform:translateY(calc(100% + var(--toast-inset)))}
.c-toast[data-position*=bottom][data-ending-style]:not([data-limited]):not([data-swipe-direction]){transform:translateY(calc(100% + var(--toast-inset)))}
.c-toast-viewport[data-position*=right]{right:var(--toast-inset)}
.c-toast[data-position*=right]{right:calc(var(--spacing)*0);left:auto}
@media (min-width:40rem){
.c-toast-viewport{--toast-inset:calc(var(--spacing)*8)}
}
.c-toast:is(.dark *){background-color:var(--popover)}
@supports (color:color-mix(in lab, red, red)){
.c-toast:is(.dark *){background-color:color-mix(in srgb,var(--popover),var(--color-black)calc(6%*max(0,var(--toast-index,0))))}
}
.c-toast:is(.dark *):before{content:var(--tw-content);--tw-shadow:0 -1px var(--tw-shadow-color,#ffffff0f)}
@supports (color:color-mix(in lab, red, red)){
.c-toast:is(.dark *):before{--tw-shadow:0 -1px var(--tw-shadow-color,color-mix(in oklab,var(--color-white)6%,transparent))}
}
.c-toast:is(.dark *):before{box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}
.c-toast:is(.dark *)[data-expanded]{background-color:var(--popover)}
.c-toast__content svg{pointer-events:none;flex-shrink:0}
.c-toast__content>svg{height:1lh;width:calc(var(--spacing)*4)}
/* propio */
.c-toast-viewport{list-style:none;margin:0;padding:0}
.c-toast{list-style:none}
.c-toast[data-starting-style],.c-toast[data-ending-style]{opacity:0}
.c-toast__icon{flex:none;width:1rem;height:1lh}
.c-toast[data-type=success] .c-toast__icon{color:var(--success)}
.c-toast[data-type=error] .c-toast__icon{color:var(--destructive)}
.c-toast[data-type=info] .c-toast__icon{color:var(--info)}
.c-toast[data-type=warning] .c-toast__icon{color:var(--warning)}
```

```js
/* Aviso (toast) con la pila de coss ui: el más nuevo delante, los demás asoman detrás y se despliegan
   al pasar el ratón o enfocar. Máximo 3 a la vista, 5 s cada uno, pausa con ratón, foco o pestaña oculta.
   Uso: Componentes.toast('Título', { description: 'Detalle', type: 'success' | 'error' | 'info' | 'warning' }) */
(function () {
  const C = (window.Componentes = window.Componentes || {});
  const CFG = { duration: 5000, limit: 3, leaveMs: 500 };
  const ICONS = {
    success: '<path d="M21.801 10A10 10 0 1 1 17 3.335"/><path d="m9 11 3 3L22 4"/>',
    error: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
    info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
    warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
  };
  let vp; const live = new Map();

  function viewport() {
    if (vp && document.contains(vp)) return vp;
    vp = document.querySelector('.c-toast-viewport');
    if (!vp) { console.warn('[toast] falta .c-toast-viewport'); return null; }
    const expand = (on) => {
      if (on) vp.setAttribute('data-expanded', ''); else vp.removeAttribute('data-expanded');
      layout();
      live.forEach((t, li) => (on ? pause(li) : resume(li)));
    };
    vp.addEventListener('pointerover', () => expand(true));
    vp.addEventListener('pointerleave', () => { if (!vp.contains(document.activeElement)) expand(false); });
    vp.addEventListener('focusin', () => expand(true));
    vp.addEventListener('focusout', (e) => { if (!vp.contains(e.relatedTarget)) expand(false); });
    document.addEventListener('visibilitychange', () => live.forEach((t, li) => (document.hidden ? pause(li) : resume(li))));
    return vp;
  }

  function layout() {
    const list = [...vp.children].filter((li) => !li.hasAttribute('data-ending-style')).reverse();
    const expanded = vp.hasAttribute('data-expanded');
    const front = list[0] ? list[0].offsetHeight : 0;
    let offset = 0;
    list.forEach((li, i) => {
      li.style.setProperty('--toast-index', i);
      li.style.setProperty('--toast-offset-y', offset + 'px');
      if (expanded) li.style.removeProperty('--toast-frontmost-height');
      else li.style.setProperty('--toast-frontmost-height', front + 'px');
      offset += li.querySelector('.c-toast__content').offsetHeight;
      li.toggleAttribute('data-behind', i > 0);
      li.toggleAttribute('data-limited', i >= CFG.limit);
      li.toggleAttribute('data-expanded', expanded);
    });
  }

  function pause(li) { const t = live.get(li); if (t && t.timer) { clearTimeout(t.timer); t.timer = 0; t.left -= Date.now() - t.start; } }
  function resume(li) {
    const t = live.get(li);
    if (!t || t.timer || document.hidden || vp.hasAttribute('data-expanded')) return;
    t.start = Date.now(); t.timer = setTimeout(() => dismiss(li), Math.max(800, t.left));
  }

  function dismiss(li) {
    pause(li); live.delete(li);
    li.setAttribute('data-ending-style', '');
    layout();
    setTimeout(() => { li.remove(); layout(); }, CFG.leaveMs);
  }

  function toast(title, opts) {
    try {
      opts = opts || {};
      if (!viewport()) return null;
      const li = document.createElement('li');
      li.className = 'c-toast';
      li.dataset.position = vp.dataset.position || 'bottom-right';
      if (opts.type) li.dataset.type = opts.type;
      li.style.setProperty('--toast-swipe-movement-x', '0px');
      li.style.setProperty('--toast-swipe-movement-y', '0px');
      const icon = ICONS[opts.type] ? `<svg class="c-toast__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[opts.type]}</svg>` : '';
      li.innerHTML = `<div class="c-toast__content"><div class="c-toast__main">${icon}<div class="c-toast__text"><p class="c-toast__title"></p>${opts.description ? '<p class="c-toast__description"></p>' : ''}</div></div></div>`;
      li.querySelector('.c-toast__title').textContent = String(title);
      if (opts.description) li.querySelector('.c-toast__description').textContent = String(opts.description);
      li.setAttribute('data-starting-style', '');
      vp.append(li);
      li.style.setProperty('--toast-height', li.querySelector('.c-toast__content').offsetHeight + 'px');
      layout();
      requestAnimationFrame(() => requestAnimationFrame(() => li.removeAttribute('data-starting-style')));
      live.set(li, { left: opts.duration || CFG.duration, start: 0, timer: 0 });
      const extra = [...vp.children].filter((x) => live.has(x)).reverse().slice(CFG.limit);
      extra.forEach(dismiss);
      resume(li);
      return li;
    } catch (e) { console.warn('[toast]', e); return null; }
  }

  document.addEventListener('click', (e) => {
    const b = e.target.closest?.('[data-c-toast]');
    if (b) toast(b.dataset.cToast, { description: b.dataset.cToastDesc, type: b.dataset.cToastType });
  });

  C.toast = toast;
})();
```

## Credits

- coss ui (MIT) · toast.tsx: https://coss.com/ui
- Emil Kowalski, Building a toast component: https://emilkowal.ski/ui/building-a-toast-component
