Toast Notification Micro-interaction
Four triggers spawn stacked toasts bottom-right: each slides in from x 40 with a back.out overshoot while earlier toasts shift up via Flip, an icon draws itself with DrawSVG, a hairline counts down 4s (pausing on hover) and dismissal re-flows the stack.
- Buttons
- gsap
- flip
- svg
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1207 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Toast Notification Micro-interaction
**What it does (visual):** A small dark "app window" (title bar with three dots, a serif heading "Fire a toast", a paragraph and four outlined pill buttons: Success, Info, Warning, Error) sits centred on the page. Pressing a button makes a notification card appear in the bottom-right corner: it slides in from 40px to the right while fading up from transparent and growing from 96% to 100% with a gentle overshoot (0.5s, `back.out(1.4)`). Any toasts already on screen glide upward to make room (Flip, 0.5s). Inside the new toast a 24px icon draws itself: first the circular ring strokes around, then the mark (a check, an "i", a "!" or a cross) draws in 0.12s later, both in the toast's tone colour — `#d4ff3a` for success, neutral for info, amber for warning, red for error. The title and message sit beside the icon, a small "×" sits top-right, and a 1px hairline in the tone colour runs across the bottom edge, shrinking from full width to zero over exactly 4 seconds. Hovering a toast freezes the hairline; leaving resumes it. When the hairline reaches zero — or when the toast is clicked — the card slides 40px right and fades out in 0.35s (accelerating) and the remaining toasts settle down into the gap (Flip, 0.4s). At most four toasts stack; a fifth dismisses the oldest. On first view three demo toasts fire automatically at 0.6s, 1.5s and 2.4s (demo salvo enabled: true).
**Tech stack:**
- GSAP 3.13+ with Flip and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `Flip.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { Flip } from "gsap/Flip"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(Flip, DrawSVGPlugin);`
- Fonts: a sans for toast text (demo: Inter Tight), a mono for labels, a serif for the page heading.
**HTML structure:**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<h1 class="tmi__h">Fire a toast</h1>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text"><p class="tmi__toast-title"></p><p class="tmi__toast-msg"></p></div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">×</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
Mark paths (24×24 box): success `M7.5 12.5l3 3 6-7`, info `M12 11v5.5M12 7.5v.5`, warning `M12 7.5v5.5M12 16v.5`, error `M8.5 8.5l7 7M15.5 8.5l-7 7`.
**CSS requirements:**
- `.tmi__stack`: `position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none;` (children re-enable `pointer-events: auto`). Because the column is anchored to the bottom, appending a toast pushes the earlier ones *up*.
- `.tmi__toast`: `--tone: var(--text); position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start; padding: 14px 12px 16px 14px; border: 1px solid var(--line, rgba(255,255,255,.08)); border-radius: 12px; background: var(--surface, #121212); box-shadow: 0 24px 60px -24px rgba(0,0,0,.65); overflow: hidden; cursor: pointer; will-change: transform, opacity;`
- Tones: `[data-type="success"] { --tone: var(--accent, #d4ff3a) }`, `[data-type="info"] { --tone: var(--text) }`, `[data-type="warning"] { --tone: #F2B544 }`, `[data-type="error"] { --tone: #FF5C5C }`.
- `.tmi__icon-ring, .tmi__icon-mark`: `fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round;`
- `.tmi__toast-title`: `.9rem`, weight 500; `.tmi__toast-msg`: `.8rem`, `color: var(--muted)`, `margin-top: 3px`.
- `.tmi__close`: 26px circle, muted, lightens on hover; visible focus ring.
- `.tmi__progress`: `position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center;`
- `.tmi__app`: `width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden;` — triggers are outlined pills that invert on hover.
- Under 768px: `.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px) }`.
**Animation spec (exact values):**
`spawn(type)`:
1. If `stack.children.length >= 4` → `dismiss(stack.firstElementChild)`.
2. Clone the template, set `data-type`, title, message and the mark's `d`.
3. `const state = Flip.getState(stack.children)` → `stack.appendChild(el)` → `Flip.from(state, { duration: 0.5, ease: 'power3.out' })` (existing toasts shift up; the new element is not in the state so Flip ignores it).
4. Entrance: `gsap.fromTo(el, { x: 40, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, duration: 0.5, ease: 'back.out(1.4)' })`.
5. Icon: `gsap.fromTo([ring, mark], { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 })`.
6. Timer: `el._timer = gsap.fromTo(bar, { scaleX: 1 }, { scaleX: 0, duration: 4, ease: 'none', delay: 0.3, onComplete: () => dismiss(el) })`.
7. Listeners: `pointerenter → el._timer.pause()`, `pointerleave → el._timer.resume()`, `click → dismiss(el)`.
`dismiss(el)`:
1. Guard with `el._leaving`; `el._timer.kill()`; `el.style.pointerEvents = 'none'`.
2. `gsap.to(el, { x: 40, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete })` where `onComplete` does: `const state = Flip.getState([...stack.children].filter(c => c !== el)); el.remove(); Flip.from(state, { duration: 0.4, ease: 'power3.out' })`.
Demo salvo (if true): `gsap.delayedCall(0.6, () => spawn('success'))`, `1.5 → 'info'`, `2.4 → 'warning'`.
**Interaction:** Trigger buttons spawn by `data-type`. Hovering a toast pauses its 4s timer (the hairline visibly stops); leaving resumes. Clicking anywhere on a toast (including the ×) dismisses it immediately with the exit tween. The stack never exceeds four.
**Responsive behavior:** The stack is full-width minus 24px under 768px; toasts keep the same grid. No `gsap.matchMedia()` branches are needed — hover-to-pause simply does not fire on touch, where the timer runs to completion (tap dismisses).
**Accessibility:** `.tmi__stack` is an `aria-live="polite"` region present in the DOM from load; each toast is `role="status"` so its text is announced once when inserted. The close button has `aria-label="Dismiss notification"` and a focus ring; the icon SVG is `aria-hidden`. Under `prefers-reduced-motion: reduce`: no Flip, no entrance/exit tweens — toasts are `gsap.set` to their final state and removed instantly; the icon is drawn at 100% immediately; only the hairline timer still runs (it is informational, not decorative).
**Performance rules:** Only `transform` and `opacity` are tweened on toasts (`x`, `scale`, `scaleX`); DrawSVG touches `stroke-dasharray/offset` on two tiny paths. `will-change: transform, opacity` is on toasts only. Everything lives in `gsap.context()`; on unmount `ctx.revert()` and `stack.replaceChildren()` clear timers and DOM. The host pauses `gsap.globalTimeline` off-screen, which also pauses every running timer.
**Customization knobs:**
- `MAX_TOASTS` (4) and `LIFETIME` (4s).
- `ENTER` (`0.5s back.out(1.4)`), slide distance (40px, shared by the entrance and exit) and `EXIT` (`0.35s power2.in`); Flip durations (0.5 / 0.4).
- Tones per type via `--tone` (success: #d4ff3a); icon stroke width (1.4).
- Demo salvo on/off (true).
- Stack position (`right/bottom: 20px`), width (360px) and `gap` (10px).
- Replace `appendChild` with `prepend` and anchor the stack to the top to get a top-right stack that pushes downward.
**Acceptance criteria:**
- [ ] A new toast enters from x 40 with a visible overshoot and existing toasts move up smoothly (no jump).
- [ ] The icon ring draws first and the mark follows 0.12s later; both in the toast's tone colour.
- [ ] The hairline takes exactly 4s to reach zero, pauses while hovered and resumes on leave; the toast is removed when it hits zero.
- [ ] Clicking a toast exits it in 0.35s and the stack re-flows; a fifth toast dismisses the oldest.
- [ ] Screen readers announce each toast; reduced motion shows toasts in place with no sliding.
- [ ] No console errors; no double-dismiss when clicking a leaving toast. Add this effect to my existing site/section: **Toast Notification Micro-interaction**. Keep my trigger buttons and styles; add a `position: fixed; right: 20px; bottom: 20px` flex column `.tmi__stack` with `aria-live="polite"` and a `<template>` toast (icon SVG with a `<circle r="10.5">` ring and a mark `<path>`, title, message, × button and a 1px bottom `.tmi__progress` with `transform-origin: left`). With GSAP Flip + DrawSVGPlugin, on spawn: `Flip.getState(stack.children)` → append → `Flip.from(state, { duration: 0.5, ease: 'power3.out' })`; enter the toast with `fromTo { x: 40, opacity: 0, scale: 0.96 } → { x: 0, opacity: 1, scale: 1, duration: 0.5, ease: 'back.out(1.4)' }`; draw the icon `drawSVG '0%' → '100%'` (0.6s `power2.out`, stagger 0.12, delay 0.15); run the timer `scaleX 1 → 0` over 4s `ease: 'none'` (pause on `pointerenter`, resume on `pointerleave`, `onComplete` dismisses). Dismiss with `x → 40, opacity → 0, 0.35s power2.in`, then `Flip.getState` of the others → remove → `Flip.from(state, { duration: 0.4 })`. Cap the stack at 4, colour by `--tone` per type (success `#d4ff3a`), optionally fire a demo salvo on load (true), wrap in `gsap.context()`, and skip the motion (set final state) under `prefers-reduced-motion`. ### EFFECT (React / Next.js): Toast Notification Micro-interaction
Build **Toast Notification Micro-interaction** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ToastNotificationMicroInteraction.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, Flip, DrawSVGPlugin);
export default function ToastNotificationMicroInteraction() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="tmi">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ToastNotificationMicroInteraction.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### EFFECT: Toast Notification Micro-interaction
**What it does (visual):** A small dark "app window" (title bar with three dots, a serif heading "Fire a toast", a paragraph and four outlined pill buttons: Success, Info, Warning, Error) sits centred on the page. Pressing a button makes a notification card appear in the bottom-right corner: it slides in from 40px to the right while fading up from transparent and growing from 96% to 100% with a gentle overshoot (0.5s, `back.out(1.4)`). Any toasts already on screen glide upward to make room (Flip, 0.5s). Inside the new toast a 24px icon draws itself: first the circular ring strokes around, then the mark (a check, an "i", a "!" or a cross) draws in 0.12s later, both in the toast's tone colour — `#d4ff3a` for success, neutral for info, amber for warning, red for error. The title and message sit beside the icon, a small "×" sits top-right, and a 1px hairline in the tone colour runs across the bottom edge, shrinking from full width to zero over exactly 4 seconds. Hovering a toast freezes the hairline; leaving resumes it. When the hairline reaches zero — or when the toast is clicked — the card slides 40px right and fades out in 0.35s (accelerating) and the remaining toasts settle down into the gap (Flip, 0.4s). At most four toasts stack; a fifth dismisses the oldest. On first view three demo toasts fire automatically at 0.6s, 1.5s and 2.4s (demo salvo enabled: true).
**Tech stack:**
- GSAP 3.13+ with Flip and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `Flip.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { Flip } from "gsap/Flip"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(Flip, DrawSVGPlugin);`
- Fonts: a sans for toast text (demo: Inter Tight), a mono for labels, a serif for the page heading.
**HTML structure:**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<h1 class="tmi__h">Fire a toast</h1>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text"><p class="tmi__toast-title"></p><p class="tmi__toast-msg"></p></div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">×</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
Mark paths (24×24 box): success `M7.5 12.5l3 3 6-7`, info `M12 11v5.5M12 7.5v.5`, warning `M12 7.5v5.5M12 16v.5`, error `M8.5 8.5l7 7M15.5 8.5l-7 7`.
**CSS requirements:**
- `.tmi__stack`: `position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none;` (children re-enable `pointer-events: auto`). Because the column is anchored to the bottom, appending a toast pushes the earlier ones *up*.
- `.tmi__toast`: `--tone: var(--text); position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start; padding: 14px 12px 16px 14px; border: 1px solid var(--line, rgba(255,255,255,.08)); border-radius: 12px; background: var(--surface, #121212); box-shadow: 0 24px 60px -24px rgba(0,0,0,.65); overflow: hidden; cursor: pointer; will-change: transform, opacity;`
- Tones: `[data-type="success"] { --tone: var(--accent, #d4ff3a) }`, `[data-type="info"] { --tone: var(--text) }`, `[data-type="warning"] { --tone: #F2B544 }`, `[data-type="error"] { --tone: #FF5C5C }`.
- `.tmi__icon-ring, .tmi__icon-mark`: `fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round;`
- `.tmi__toast-title`: `.9rem`, weight 500; `.tmi__toast-msg`: `.8rem`, `color: var(--muted)`, `margin-top: 3px`.
- `.tmi__close`: 26px circle, muted, lightens on hover; visible focus ring.
- `.tmi__progress`: `position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center;`
- `.tmi__app`: `width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden;` — triggers are outlined pills that invert on hover.
- Under 768px: `.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px) }`.
**Animation spec (exact values):**
`spawn(type)`:
1. If `stack.children.length >= 4` → `dismiss(stack.firstElementChild)`.
2. Clone the template, set `data-type`, title, message and the mark's `d`.
3. `const state = Flip.getState(stack.children)` → `stack.appendChild(el)` → `Flip.from(state, { duration: 0.5, ease: 'power3.out' })` (existing toasts shift up; the new element is not in the state so Flip ignores it).
4. Entrance: `gsap.fromTo(el, { x: 40, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, duration: 0.5, ease: 'back.out(1.4)' })`.
5. Icon: `gsap.fromTo([ring, mark], { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 })`.
6. Timer: `el._timer = gsap.fromTo(bar, { scaleX: 1 }, { scaleX: 0, duration: 4, ease: 'none', delay: 0.3, onComplete: () => dismiss(el) })`.
7. Listeners: `pointerenter → el._timer.pause()`, `pointerleave → el._timer.resume()`, `click → dismiss(el)`.
`dismiss(el)`:
1. Guard with `el._leaving`; `el._timer.kill()`; `el.style.pointerEvents = 'none'`.
2. `gsap.to(el, { x: 40, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete })` where `onComplete` does: `const state = Flip.getState([...stack.children].filter(c => c !== el)); el.remove(); Flip.from(state, { duration: 0.4, ease: 'power3.out' })`.
Demo salvo (if true): `gsap.delayedCall(0.6, () => spawn('success'))`, `1.5 → 'info'`, `2.4 → 'warning'`.
**Interaction:** Trigger buttons spawn by `data-type`. Hovering a toast pauses its 4s timer (the hairline visibly stops); leaving resumes. Clicking anywhere on a toast (including the ×) dismisses it immediately with the exit tween. The stack never exceeds four.
**Responsive behavior:** The stack is full-width minus 24px under 768px; toasts keep the same grid. No `gsap.matchMedia()` branches are needed — hover-to-pause simply does not fire on touch, where the timer runs to completion (tap dismisses).
**Accessibility:** `.tmi__stack` is an `aria-live="polite"` region present in the DOM from load; each toast is `role="status"` so its text is announced once when inserted. The close button has `aria-label="Dismiss notification"` and a focus ring; the icon SVG is `aria-hidden`. Under `prefers-reduced-motion: reduce`: no Flip, no entrance/exit tweens — toasts are `gsap.set` to their final state and removed instantly; the icon is drawn at 100% immediately; only the hairline timer still runs (it is informational, not decorative).
**Performance rules:** Only `transform` and `opacity` are tweened on toasts (`x`, `scale`, `scaleX`); DrawSVG touches `stroke-dasharray/offset` on two tiny paths. `will-change: transform, opacity` is on toasts only. Everything lives in `gsap.context()`; on unmount `ctx.revert()` and `stack.replaceChildren()` clear timers and DOM. The host pauses `gsap.globalTimeline` off-screen, which also pauses every running timer.
**Customization knobs:**
- `MAX_TOASTS` (4) and `LIFETIME` (4s).
- `ENTER` (`0.5s back.out(1.4)`), slide distance (40px, shared by the entrance and exit) and `EXIT` (`0.35s power2.in`); Flip durations (0.5 / 0.4).
- Tones per type via `--tone` (success: #d4ff3a); icon stroke width (1.4).
- Demo salvo on/off (true).
- Stack position (`right/bottom: 20px`), width (360px) and `gap` (10px).
- Replace `appendChild` with `prepend` and anchor the stack to the top to get a top-right stack that pushes downward.
**Acceptance criteria:**
- [ ] A new toast enters from x 40 with a visible overshoot and existing toasts move up smoothly (no jump).
- [ ] The icon ring draws first and the mark follows 0.12s later; both in the toast's tone colour.
- [ ] The hairline takes exactly 4s to reach zero, pauses while hovered and resumes on leave; the toast is removed when it hits zero.
- [ ] Clicking a toast exits it in 0.35s and the stack re-flows; a fifth toast dismisses the oldest.
- [ ] Screen readers announce each toast; reduced motion shows toasts in place with no sliding.
- [ ] No console errors; no double-dismiss when clicking a leaving toast.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<p class="tmi__eyebrow">Button · Notification stack</p>
<h1 class="tmi__h">Fire a toast</h1>
<p class="tmi__p">Each notification slides in from the right with a soft overshoot, the pile re-flows with Flip, an icon draws itself in and a hairline counts down four seconds — hover to pause it.</p>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text">
<p class="tmi__toast-title"></p>
<p class="tmi__toast-msg"></p>
</div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
```css
.tmi { --tmi-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; place-items: center; padding: clamp(1.25rem, 5vw, 4rem) 6vw; background: var(--bg); color: var(--text); }
/* ---------- tiny app window ---------- */
.tmi__app { width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden; }
.tmi__bar { display: flex; align-items: center; gap: 6px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.tmi__dot { width: 9px; height: 9px; border-radius: 50%; background: color-mix(in srgb, var(--text) 14%, transparent); }
.tmi__bar-title { margin-left: 10px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.tmi__body { padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 4vw, 3.5rem) clamp(1.75rem, 4vw, 3.5rem); }
.tmi__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.tmi__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.5rem, 6vw, 5rem); letter-spacing: -0.04em; line-height: .95; margin: .35em 0 .45em; }
.tmi__p { max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.1vw, 1.05rem); line-height: 1.55; }
.tmi__triggers { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 2rem; }
.tmi__trigger {
padding: .7em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 22%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; transition: background .2s, color .2s, border-color .2s;
}
.tmi__trigger:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.tmi__trigger:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 3px; }
/* ---------- stack ---------- */
.tmi__stack { position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none; }
.tmi__toast {
--tone: var(--text);
position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start;
padding: 14px 12px 16px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--text);
box-shadow: 0 24px 60px -24px rgba(0,0,0,.65), 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent) inset;
overflow: hidden; pointer-events: auto; cursor: pointer; will-change: transform, opacity;
}
.tmi__toast[data-type="success"] { --tone: var(--tmi-accent); }
.tmi__toast[data-type="info"] { --tone: var(--text); }
.tmi__toast[data-type="warning"] { --tone: #F2B544; }
.tmi__toast[data-type="error"] { --tone: #FF5C5C; }
.tmi__icon { margin-top: 1px; }
.tmi__icon-ring, .tmi__icon-mark { fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.tmi__toast-title { font-size: .9rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.3; }
.tmi__toast-msg { margin-top: 3px; font-size: .8rem; color: var(--muted); line-height: 1.45; }
.tmi__close { display: grid; place-items: center; width: 26px; height: 26px; margin: -4px -2px 0 0; border-radius: 50%; color: var(--muted); transition: background .2s, color .2s; }
.tmi__close:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
.tmi__close:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 2px; }
.tmi__progress { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center; }
:root[data-theme="light"] .tmi__toast { box-shadow: 0 24px 60px -28px rgba(0,0,0,.35); }
@media (max-width: 767px) {
.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px); }
}
/* reduced motion: toasts appear/disappear in place; only the timer hairline changes */
@media (prefers-reduced-motion: reduce) {
.tmi__toast { will-change: auto; }
}
```
```js
// Toast Notification Micro-interaction — back.out entrance, Flip re-flow, DrawSVG icon, hairline timer.
gsap.registerPlugin(Flip, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lifetime: 4, enterDuration: 0.5, overshoot: 1.4, slide: 40, demoSalvo: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_TOASTS = 4;
const EXIT = { duration: 0.35, ease: 'power2.in' };
const TYPES = {
success: { title: 'Order #4812 confirmed', msg: 'Receipt sent to the customer.', mark: 'M7.5 12.5l3 3 6-7' },
info: { title: 'Inventory synced', msg: '142 SKUs updated from the Oslo warehouse.', mark: 'M12 11v5.5M12 7.5v.5' },
warning: { title: 'Low stock: Linen Throw', msg: 'Only 3 units left — reorder point is 12.', mark: 'M12 7.5v5.5M12 16v.5' },
error: { title: 'Payment declined', msg: 'Card ending 4421 was refused by the issuer.', mark: 'M8.5 8.5l7 7M15.5 8.5l-7 7' },
};
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.tmi');
const stack = root.querySelector('.tmi__stack');
const tpl = document.getElementById('tmi-toast');
const triggers = root.querySelectorAll('.tmi__trigger');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
stack.replaceChildren();
root.style.setProperty('--knob-accent', K('accent'));
const LIFETIME = K('lifetime'); // seconds before auto-dismiss
const SLIDE = K('slide'); // px, entrance/exit x offset
const ENTER = { duration: K('enterDuration'), ease: `back.out(${K('overshoot')})` };
ctx = gsap.context(() => {
const spawn = (type) => {
const data = TYPES[type];
if (stack.children.length >= MAX_TOASTS) dismiss(stack.firstElementChild);
const el = tpl.content.firstElementChild.cloneNode(true);
el.dataset.type = type;
el.querySelector('.tmi__toast-title').textContent = data.title;
el.querySelector('.tmi__toast-msg').textContent = data.msg;
el.querySelector('.tmi__icon-mark').setAttribute('d', data.mark);
const bar = el.querySelector('.tmi__progress');
const icon = [el.querySelector('.tmi__icon-ring'), el.querySelector('.tmi__icon-mark')];
// 1. record where the existing toasts are, 2. mutate, 3. let Flip animate the shift
const state = Flip.getState(stack.children);
stack.appendChild(el);
if (!reduced) Flip.from(state, { duration: 0.5, ease: 'power3.out' });
if (reduced) {
gsap.set(el, { x: 0, opacity: 1, scale: 1 });
gsap.set(icon, { drawSVG: '100%' });
} else {
gsap.fromTo(el, { x: SLIDE, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, ...ENTER });
gsap.fromTo(icon, { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 });
}
// the hairline IS the timer
el._timer = gsap.fromTo(bar, { scaleX: 1 }, {
scaleX: 0, duration: LIFETIME, ease: 'none', delay: 0.3,
onComplete: () => dismiss(el),
});
el.addEventListener('pointerenter', () => el._timer.pause());
el.addEventListener('pointerleave', () => el._timer.resume());
el.addEventListener('click', () => dismiss(el));
};
const dismiss = (el) => {
if (!el || el._leaving) return;
el._leaving = true;
el._timer?.kill();
el.style.pointerEvents = 'none';
const remove = () => {
const others = [...stack.children].filter((c) => c !== el);
const state = Flip.getState(others);
el.remove();
if (!reduced) Flip.from(state, { duration: 0.4, ease: 'power3.out' });
};
if (reduced) remove();
else gsap.to(el, { x: SLIDE, opacity: 0, ...EXIT, onComplete: remove });
};
triggers.forEach((btn) => btn.addEventListener('click', () => spawn(btn.dataset.type), { signal: ac.signal }));
// a short demo salvo so the stack is never empty on first view
if (K('demoSalvo')) {
gsap.delayedCall(0.6, () => spawn('success'));
gsap.delayedCall(1.5, () => spawn('info'));
gsap.delayedCall(2.4, () => spawn('warning'));
}
});
}
build();
// Playground: rebuild (clears the stack, re-fires the salvo) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); stack.replaceChildren(); });
```
### EFFECT (single HTML file): Toast Notification Micro-interaction
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Toast Notification Micro-interaction** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Toast Notification Micro-interaction</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Flip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/DrawSVGPlugin.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Toast Notification Micro-interaction
**What it does (visual):** A small dark "app window" (title bar with three dots, a serif heading "Fire a toast", a paragraph and four outlined pill buttons: Success, Info, Warning, Error) sits centred on the page. Pressing a button makes a notification card appear in the bottom-right corner: it slides in from 40px to the right while fading up from transparent and growing from 96% to 100% with a gentle overshoot (0.5s, `back.out(1.4)`). Any toasts already on screen glide upward to make room (Flip, 0.5s). Inside the new toast a 24px icon draws itself: first the circular ring strokes around, then the mark (a check, an "i", a "!" or a cross) draws in 0.12s later, both in the toast's tone colour — `#d4ff3a` for success, neutral for info, amber for warning, red for error. The title and message sit beside the icon, a small "×" sits top-right, and a 1px hairline in the tone colour runs across the bottom edge, shrinking from full width to zero over exactly 4 seconds. Hovering a toast freezes the hairline; leaving resumes it. When the hairline reaches zero — or when the toast is clicked — the card slides 40px right and fades out in 0.35s (accelerating) and the remaining toasts settle down into the gap (Flip, 0.4s). At most four toasts stack; a fifth dismisses the oldest. On first view three demo toasts fire automatically at 0.6s, 1.5s and 2.4s (demo salvo enabled: true).
**Tech stack:**
- GSAP 3.13+ with Flip and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `Flip.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { Flip } from "gsap/Flip"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(Flip, DrawSVGPlugin);`
- Fonts: a sans for toast text (demo: Inter Tight), a mono for labels, a serif for the page heading.
**HTML structure:**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<h1 class="tmi__h">Fire a toast</h1>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text"><p class="tmi__toast-title"></p><p class="tmi__toast-msg"></p></div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">×</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
Mark paths (24×24 box): success `M7.5 12.5l3 3 6-7`, info `M12 11v5.5M12 7.5v.5`, warning `M12 7.5v5.5M12 16v.5`, error `M8.5 8.5l7 7M15.5 8.5l-7 7`.
**CSS requirements:**
- `.tmi__stack`: `position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none;` (children re-enable `pointer-events: auto`). Because the column is anchored to the bottom, appending a toast pushes the earlier ones *up*.
- `.tmi__toast`: `--tone: var(--text); position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start; padding: 14px 12px 16px 14px; border: 1px solid var(--line, rgba(255,255,255,.08)); border-radius: 12px; background: var(--surface, #121212); box-shadow: 0 24px 60px -24px rgba(0,0,0,.65); overflow: hidden; cursor: pointer; will-change: transform, opacity;`
- Tones: `[data-type="success"] { --tone: var(--accent, #d4ff3a) }`, `[data-type="info"] { --tone: var(--text) }`, `[data-type="warning"] { --tone: #F2B544 }`, `[data-type="error"] { --tone: #FF5C5C }`.
- `.tmi__icon-ring, .tmi__icon-mark`: `fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round;`
- `.tmi__toast-title`: `.9rem`, weight 500; `.tmi__toast-msg`: `.8rem`, `color: var(--muted)`, `margin-top: 3px`.
- `.tmi__close`: 26px circle, muted, lightens on hover; visible focus ring.
- `.tmi__progress`: `position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center;`
- `.tmi__app`: `width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden;` — triggers are outlined pills that invert on hover.
- Under 768px: `.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px) }`.
**Animation spec (exact values):**
`spawn(type)`:
1. If `stack.children.length >= 4` → `dismiss(stack.firstElementChild)`.
2. Clone the template, set `data-type`, title, message and the mark's `d`.
3. `const state = Flip.getState(stack.children)` → `stack.appendChild(el)` → `Flip.from(state, { duration: 0.5, ease: 'power3.out' })` (existing toasts shift up; the new element is not in the state so Flip ignores it).
4. Entrance: `gsap.fromTo(el, { x: 40, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, duration: 0.5, ease: 'back.out(1.4)' })`.
5. Icon: `gsap.fromTo([ring, mark], { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 })`.
6. Timer: `el._timer = gsap.fromTo(bar, { scaleX: 1 }, { scaleX: 0, duration: 4, ease: 'none', delay: 0.3, onComplete: () => dismiss(el) })`.
7. Listeners: `pointerenter → el._timer.pause()`, `pointerleave → el._timer.resume()`, `click → dismiss(el)`.
`dismiss(el)`:
1. Guard with `el._leaving`; `el._timer.kill()`; `el.style.pointerEvents = 'none'`.
2. `gsap.to(el, { x: 40, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete })` where `onComplete` does: `const state = Flip.getState([...stack.children].filter(c => c !== el)); el.remove(); Flip.from(state, { duration: 0.4, ease: 'power3.out' })`.
Demo salvo (if true): `gsap.delayedCall(0.6, () => spawn('success'))`, `1.5 → 'info'`, `2.4 → 'warning'`.
**Interaction:** Trigger buttons spawn by `data-type`. Hovering a toast pauses its 4s timer (the hairline visibly stops); leaving resumes. Clicking anywhere on a toast (including the ×) dismisses it immediately with the exit tween. The stack never exceeds four.
**Responsive behavior:** The stack is full-width minus 24px under 768px; toasts keep the same grid. No `gsap.matchMedia()` branches are needed — hover-to-pause simply does not fire on touch, where the timer runs to completion (tap dismisses).
**Accessibility:** `.tmi__stack` is an `aria-live="polite"` region present in the DOM from load; each toast is `role="status"` so its text is announced once when inserted. The close button has `aria-label="Dismiss notification"` and a focus ring; the icon SVG is `aria-hidden`. Under `prefers-reduced-motion: reduce`: no Flip, no entrance/exit tweens — toasts are `gsap.set` to their final state and removed instantly; the icon is drawn at 100% immediately; only the hairline timer still runs (it is informational, not decorative).
**Performance rules:** Only `transform` and `opacity` are tweened on toasts (`x`, `scale`, `scaleX`); DrawSVG touches `stroke-dasharray/offset` on two tiny paths. `will-change: transform, opacity` is on toasts only. Everything lives in `gsap.context()`; on unmount `ctx.revert()` and `stack.replaceChildren()` clear timers and DOM. The host pauses `gsap.globalTimeline` off-screen, which also pauses every running timer.
**Customization knobs:**
- `MAX_TOASTS` (4) and `LIFETIME` (4s).
- `ENTER` (`0.5s back.out(1.4)`), slide distance (40px, shared by the entrance and exit) and `EXIT` (`0.35s power2.in`); Flip durations (0.5 / 0.4).
- Tones per type via `--tone` (success: #d4ff3a); icon stroke width (1.4).
- Demo salvo on/off (true).
- Stack position (`right/bottom: 20px`), width (360px) and `gap` (10px).
- Replace `appendChild` with `prepend` and anchor the stack to the top to get a top-right stack that pushes downward.
**Acceptance criteria:**
- [ ] A new toast enters from x 40 with a visible overshoot and existing toasts move up smoothly (no jump).
- [ ] The icon ring draws first and the mark follows 0.12s later; both in the toast's tone colour.
- [ ] The hairline takes exactly 4s to reach zero, pauses while hovered and resumes on leave; the toast is removed when it hits zero.
- [ ] Clicking a toast exits it in 0.35s and the stack re-flows; a fifth toast dismisses the oldest.
- [ ] Screen readers announce each toast; reduced motion shows toasts in place with no sliding.
- [ ] No console errors; no double-dismiss when clicking a leaving toast.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<p class="tmi__eyebrow">Button · Notification stack</p>
<h1 class="tmi__h">Fire a toast</h1>
<p class="tmi__p">Each notification slides in from the right with a soft overshoot, the pile re-flows with Flip, an icon draws itself in and a hairline counts down four seconds — hover to pause it.</p>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text">
<p class="tmi__toast-title"></p>
<p class="tmi__toast-msg"></p>
</div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
```css
.tmi { --tmi-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; place-items: center; padding: clamp(1.25rem, 5vw, 4rem) 6vw; background: var(--bg); color: var(--text); }
/* ---------- tiny app window ---------- */
.tmi__app { width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden; }
.tmi__bar { display: flex; align-items: center; gap: 6px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.tmi__dot { width: 9px; height: 9px; border-radius: 50%; background: color-mix(in srgb, var(--text) 14%, transparent); }
.tmi__bar-title { margin-left: 10px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.tmi__body { padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 4vw, 3.5rem) clamp(1.75rem, 4vw, 3.5rem); }
.tmi__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.tmi__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.5rem, 6vw, 5rem); letter-spacing: -0.04em; line-height: .95; margin: .35em 0 .45em; }
.tmi__p { max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.1vw, 1.05rem); line-height: 1.55; }
.tmi__triggers { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 2rem; }
.tmi__trigger {
padding: .7em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 22%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; transition: background .2s, color .2s, border-color .2s;
}
.tmi__trigger:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.tmi__trigger:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 3px; }
/* ---------- stack ---------- */
.tmi__stack { position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none; }
.tmi__toast {
--tone: var(--text);
position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start;
padding: 14px 12px 16px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--text);
box-shadow: 0 24px 60px -24px rgba(0,0,0,.65), 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent) inset;
overflow: hidden; pointer-events: auto; cursor: pointer; will-change: transform, opacity;
}
.tmi__toast[data-type="success"] { --tone: var(--tmi-accent); }
.tmi__toast[data-type="info"] { --tone: var(--text); }
.tmi__toast[data-type="warning"] { --tone: #F2B544; }
.tmi__toast[data-type="error"] { --tone: #FF5C5C; }
.tmi__icon { margin-top: 1px; }
.tmi__icon-ring, .tmi__icon-mark { fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.tmi__toast-title { font-size: .9rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.3; }
.tmi__toast-msg { margin-top: 3px; font-size: .8rem; color: var(--muted); line-height: 1.45; }
.tmi__close { display: grid; place-items: center; width: 26px; height: 26px; margin: -4px -2px 0 0; border-radius: 50%; color: var(--muted); transition: background .2s, color .2s; }
.tmi__close:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
.tmi__close:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 2px; }
.tmi__progress { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center; }
:root[data-theme="light"] .tmi__toast { box-shadow: 0 24px 60px -28px rgba(0,0,0,.35); }
@media (max-width: 767px) {
.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px); }
}
/* reduced motion: toasts appear/disappear in place; only the timer hairline changes */
@media (prefers-reduced-motion: reduce) {
.tmi__toast { will-change: auto; }
}
```
```js
// Toast Notification Micro-interaction — back.out entrance, Flip re-flow, DrawSVG icon, hairline timer.
gsap.registerPlugin(Flip, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lifetime: 4, enterDuration: 0.5, overshoot: 1.4, slide: 40, demoSalvo: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_TOASTS = 4;
const EXIT = { duration: 0.35, ease: 'power2.in' };
const TYPES = {
success: { title: 'Order #4812 confirmed', msg: 'Receipt sent to the customer.', mark: 'M7.5 12.5l3 3 6-7' },
info: { title: 'Inventory synced', msg: '142 SKUs updated from the Oslo warehouse.', mark: 'M12 11v5.5M12 7.5v.5' },
warning: { title: 'Low stock: Linen Throw', msg: 'Only 3 units left — reorder point is 12.', mark: 'M12 7.5v5.5M12 16v.5' },
error: { title: 'Payment declined', msg: 'Card ending 4421 was refused by the issuer.', mark: 'M8.5 8.5l7 7M15.5 8.5l-7 7' },
};
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.tmi');
const stack = root.querySelector('.tmi__stack');
const tpl = document.getElementById('tmi-toast');
const triggers = root.querySelectorAll('.tmi__trigger');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
stack.replaceChildren();
root.style.setProperty('--knob-accent', K('accent'));
const LIFETIME = K('lifetime'); // seconds before auto-dismiss
const SLIDE = K('slide'); // px, entrance/exit x offset
const ENTER = { duration: K('enterDuration'), ease: `back.out(${K('overshoot')})` };
ctx = gsap.context(() => {
const spawn = (type) => {
const data = TYPES[type];
if (stack.children.length >= MAX_TOASTS) dismiss(stack.firstElementChild);
const el = tpl.content.firstElementChild.cloneNode(true);
el.dataset.type = type;
el.querySelector('.tmi__toast-title').textContent = data.title;
el.querySelector('.tmi__toast-msg').textContent = data.msg;
el.querySelector('.tmi__icon-mark').setAttribute('d', data.mark);
const bar = el.querySelector('.tmi__progress');
const icon = [el.querySelector('.tmi__icon-ring'), el.querySelector('.tmi__icon-mark')];
// 1. record where the existing toasts are, 2. mutate, 3. let Flip animate the shift
const state = Flip.getState(stack.children);
stack.appendChild(el);
if (!reduced) Flip.from(state, { duration: 0.5, ease: 'power3.out' });
if (reduced) {
gsap.set(el, { x: 0, opacity: 1, scale: 1 });
gsap.set(icon, { drawSVG: '100%' });
} else {
gsap.fromTo(el, { x: SLIDE, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, ...ENTER });
gsap.fromTo(icon, { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 });
}
// the hairline IS the timer
el._timer = gsap.fromTo(bar, { scaleX: 1 }, {
scaleX: 0, duration: LIFETIME, ease: 'none', delay: 0.3,
onComplete: () => dismiss(el),
});
el.addEventListener('pointerenter', () => el._timer.pause());
el.addEventListener('pointerleave', () => el._timer.resume());
el.addEventListener('click', () => dismiss(el));
};
const dismiss = (el) => {
if (!el || el._leaving) return;
el._leaving = true;
el._timer?.kill();
el.style.pointerEvents = 'none';
const remove = () => {
const others = [...stack.children].filter((c) => c !== el);
const state = Flip.getState(others);
el.remove();
if (!reduced) Flip.from(state, { duration: 0.4, ease: 'power3.out' });
};
if (reduced) remove();
else gsap.to(el, { x: SLIDE, opacity: 0, ...EXIT, onComplete: remove });
};
triggers.forEach((btn) => btn.addEventListener('click', () => spawn(btn.dataset.type), { signal: ac.signal }));
// a short demo salvo so the stack is never empty on first view
if (K('demoSalvo')) {
gsap.delayedCall(0.6, () => spawn('success'));
gsap.delayedCall(1.5, () => spawn('info'));
gsap.delayedCall(2.4, () => spawn('warning'));
}
});
}
build();
// Playground: rebuild (clears the stack, re-fires the salvo) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); stack.replaceChildren(); });
```
### EFFECT (Framer & Webflow notes): Toast Notification Micro-interaction
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withToastNotificationMicrointeraction(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `tmi`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Flip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/DrawSVGPlugin.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Toast Notification Micro-interaction
**What it does (visual):** A small dark "app window" (title bar with three dots, a serif heading "Fire a toast", a paragraph and four outlined pill buttons: Success, Info, Warning, Error) sits centred on the page. Pressing a button makes a notification card appear in the bottom-right corner: it slides in from 40px to the right while fading up from transparent and growing from 96% to 100% with a gentle overshoot (0.5s, `back.out(1.4)`). Any toasts already on screen glide upward to make room (Flip, 0.5s). Inside the new toast a 24px icon draws itself: first the circular ring strokes around, then the mark (a check, an "i", a "!" or a cross) draws in 0.12s later, both in the toast's tone colour — `#d4ff3a` for success, neutral for info, amber for warning, red for error. The title and message sit beside the icon, a small "×" sits top-right, and a 1px hairline in the tone colour runs across the bottom edge, shrinking from full width to zero over exactly 4 seconds. Hovering a toast freezes the hairline; leaving resumes it. When the hairline reaches zero — or when the toast is clicked — the card slides 40px right and fades out in 0.35s (accelerating) and the remaining toasts settle down into the gap (Flip, 0.4s). At most four toasts stack; a fifth dismisses the oldest. On first view three demo toasts fire automatically at 0.6s, 1.5s and 2.4s (demo salvo enabled: true).
**Tech stack:**
- GSAP 3.13+ with Flip and DrawSVGPlugin (both free since 3.13).
- CDN: `gsap.min.js`, `Flip.min.js`, `DrawSVGPlugin.min.js` from `https://cdn.jsdelivr.net/npm/gsap@3/dist/`.
- npm: `import gsap from "gsap"; import { Flip } from "gsap/Flip"; import { DrawSVGPlugin } from "gsap/DrawSVGPlugin"; gsap.registerPlugin(Flip, DrawSVGPlugin);`
- Fonts: a sans for toast text (demo: Inter Tight), a mono for labels, a serif for the page heading.
**HTML structure:**
```html
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<h1 class="tmi__h">Fire a toast</h1>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text"><p class="tmi__toast-title"></p><p class="tmi__toast-msg"></p></div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">×</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
```
Mark paths (24×24 box): success `M7.5 12.5l3 3 6-7`, info `M12 11v5.5M12 7.5v.5`, warning `M12 7.5v5.5M12 16v.5`, error `M8.5 8.5l7 7M15.5 8.5l-7 7`.
**CSS requirements:**
- `.tmi__stack`: `position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none;` (children re-enable `pointer-events: auto`). Because the column is anchored to the bottom, appending a toast pushes the earlier ones *up*.
- `.tmi__toast`: `--tone: var(--text); position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start; padding: 14px 12px 16px 14px; border: 1px solid var(--line, rgba(255,255,255,.08)); border-radius: 12px; background: var(--surface, #121212); box-shadow: 0 24px 60px -24px rgba(0,0,0,.65); overflow: hidden; cursor: pointer; will-change: transform, opacity;`
- Tones: `[data-type="success"] { --tone: var(--accent, #d4ff3a) }`, `[data-type="info"] { --tone: var(--text) }`, `[data-type="warning"] { --tone: #F2B544 }`, `[data-type="error"] { --tone: #FF5C5C }`.
- `.tmi__icon-ring, .tmi__icon-mark`: `fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round;`
- `.tmi__toast-title`: `.9rem`, weight 500; `.tmi__toast-msg`: `.8rem`, `color: var(--muted)`, `margin-top: 3px`.
- `.tmi__close`: 26px circle, muted, lightens on hover; visible focus ring.
- `.tmi__progress`: `position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center;`
- `.tmi__app`: `width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden;` — triggers are outlined pills that invert on hover.
- Under 768px: `.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px) }`.
**Animation spec (exact values):**
`spawn(type)`:
1. If `stack.children.length >= 4` → `dismiss(stack.firstElementChild)`.
2. Clone the template, set `data-type`, title, message and the mark's `d`.
3. `const state = Flip.getState(stack.children)` → `stack.appendChild(el)` → `Flip.from(state, { duration: 0.5, ease: 'power3.out' })` (existing toasts shift up; the new element is not in the state so Flip ignores it).
4. Entrance: `gsap.fromTo(el, { x: 40, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, duration: 0.5, ease: 'back.out(1.4)' })`.
5. Icon: `gsap.fromTo([ring, mark], { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 })`.
6. Timer: `el._timer = gsap.fromTo(bar, { scaleX: 1 }, { scaleX: 0, duration: 4, ease: 'none', delay: 0.3, onComplete: () => dismiss(el) })`.
7. Listeners: `pointerenter → el._timer.pause()`, `pointerleave → el._timer.resume()`, `click → dismiss(el)`.
`dismiss(el)`:
1. Guard with `el._leaving`; `el._timer.kill()`; `el.style.pointerEvents = 'none'`.
2. `gsap.to(el, { x: 40, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete })` where `onComplete` does: `const state = Flip.getState([...stack.children].filter(c => c !== el)); el.remove(); Flip.from(state, { duration: 0.4, ease: 'power3.out' })`.
Demo salvo (if true): `gsap.delayedCall(0.6, () => spawn('success'))`, `1.5 → 'info'`, `2.4 → 'warning'`.
**Interaction:** Trigger buttons spawn by `data-type`. Hovering a toast pauses its 4s timer (the hairline visibly stops); leaving resumes. Clicking anywhere on a toast (including the ×) dismisses it immediately with the exit tween. The stack never exceeds four.
**Responsive behavior:** The stack is full-width minus 24px under 768px; toasts keep the same grid. No `gsap.matchMedia()` branches are needed — hover-to-pause simply does not fire on touch, where the timer runs to completion (tap dismisses).
**Accessibility:** `.tmi__stack` is an `aria-live="polite"` region present in the DOM from load; each toast is `role="status"` so its text is announced once when inserted. The close button has `aria-label="Dismiss notification"` and a focus ring; the icon SVG is `aria-hidden`. Under `prefers-reduced-motion: reduce`: no Flip, no entrance/exit tweens — toasts are `gsap.set` to their final state and removed instantly; the icon is drawn at 100% immediately; only the hairline timer still runs (it is informational, not decorative).
**Performance rules:** Only `transform` and `opacity` are tweened on toasts (`x`, `scale`, `scaleX`); DrawSVG touches `stroke-dasharray/offset` on two tiny paths. `will-change: transform, opacity` is on toasts only. Everything lives in `gsap.context()`; on unmount `ctx.revert()` and `stack.replaceChildren()` clear timers and DOM. The host pauses `gsap.globalTimeline` off-screen, which also pauses every running timer.
**Customization knobs:**
- `MAX_TOASTS` (4) and `LIFETIME` (4s).
- `ENTER` (`0.5s back.out(1.4)`), slide distance (40px, shared by the entrance and exit) and `EXIT` (`0.35s power2.in`); Flip durations (0.5 / 0.4).
- Tones per type via `--tone` (success: #d4ff3a); icon stroke width (1.4).
- Demo salvo on/off (true).
- Stack position (`right/bottom: 20px`), width (360px) and `gap` (10px).
- Replace `appendChild` with `prepend` and anchor the stack to the top to get a top-right stack that pushes downward.
**Acceptance criteria:**
- [ ] A new toast enters from x 40 with a visible overshoot and existing toasts move up smoothly (no jump).
- [ ] The icon ring draws first and the mark follows 0.12s later; both in the toast's tone colour.
- [ ] The hairline takes exactly 4s to reach zero, pauses while hovered and resumes on leave; the toast is removed when it hits zero.
- [ ] Clicking a toast exits it in 0.35s and the stack re-flows; a fifth toast dismisses the oldest.
- [ ] Screen readers announce each toast; reduced motion shows toasts in place with no sliding.
- [ ] No console errors; no double-dismiss when clicking a leaving toast.
Source
Full source code
<section class="tmi">
<div class="tmi__app">
<header class="tmi__bar" aria-hidden="true"><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__dot"></span><span class="tmi__bar-title">Ledger · Orders</span></header>
<div class="tmi__body">
<p class="tmi__eyebrow">Button · Notification stack</p>
<h1 class="tmi__h">Fire a toast</h1>
<p class="tmi__p">Each notification slides in from the right with a soft overshoot, the pile re-flows with Flip, an icon draws itself in and a hairline counts down four seconds — hover to pause it.</p>
<div class="tmi__triggers">
<button class="tmi__trigger" type="button" data-type="success">Success</button>
<button class="tmi__trigger" type="button" data-type="info">Info</button>
<button class="tmi__trigger" type="button" data-type="warning">Warning</button>
<button class="tmi__trigger" type="button" data-type="error">Error</button>
</div>
</div>
</div>
<div class="tmi__stack" role="region" aria-live="polite" aria-label="Notifications"></div>
<template id="tmi-toast">
<div class="tmi__toast" role="status">
<svg class="tmi__icon" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
<circle class="tmi__icon-ring" cx="12" cy="12" r="10.5"/>
<path class="tmi__icon-mark" d=""/>
</svg>
<div class="tmi__text">
<p class="tmi__toast-title"></p>
<p class="tmi__toast-msg"></p>
</div>
<button class="tmi__close" type="button" aria-label="Dismiss notification">
<svg viewBox="0 0 12 12" width="12" height="12" aria-hidden="true"><path d="M2 2l8 8M10 2l-8 8" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
</button>
<span class="tmi__progress" aria-hidden="true"></span>
</div>
</template>
</section>
.tmi { --tmi-accent: var(--knob-accent, var(--accent)); position: relative; min-height: 100vh; display: grid; place-items: center; padding: clamp(1.25rem, 5vw, 4rem) 6vw; background: var(--bg); color: var(--text); }
/* ---------- tiny app window ---------- */
.tmi__app { width: min(100%, 760px); border: 1px solid var(--line); border-radius: 14px; background: var(--surface); overflow: hidden; }
.tmi__bar { display: flex; align-items: center; gap: 6px; padding: 12px 14px; border-bottom: 1px solid var(--line); }
.tmi__dot { width: 9px; height: 9px; border-radius: 50%; background: color-mix(in srgb, var(--text) 14%, transparent); }
.tmi__bar-title { margin-left: 10px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.tmi__body { padding: clamp(1.5rem, 4vw, 3.5rem) clamp(1.25rem, 4vw, 3.5rem) clamp(1.75rem, 4vw, 3.5rem); }
.tmi__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.tmi__h { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.5rem, 6vw, 5rem); letter-spacing: -0.04em; line-height: .95; margin: .35em 0 .45em; }
.tmi__p { max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.1vw, 1.05rem); line-height: 1.55; }
.tmi__triggers { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 2rem; }
.tmi__trigger {
padding: .7em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 22%, transparent); border-radius: 999px;
font-size: .85rem; font-weight: 500; transition: background .2s, color .2s, border-color .2s;
}
.tmi__trigger:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.tmi__trigger:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 3px; }
/* ---------- stack ---------- */
.tmi__stack { position: fixed; right: 20px; bottom: 20px; z-index: 20; display: flex; flex-direction: column; align-items: stretch; gap: 10px; width: min(360px, calc(100vw - 40px)); pointer-events: none; }
.tmi__toast {
--tone: var(--text);
position: relative; display: grid; grid-template-columns: 24px 1fr auto; gap: 12px; align-items: start;
padding: 14px 12px 16px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--text);
box-shadow: 0 24px 60px -24px rgba(0,0,0,.65), 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent) inset;
overflow: hidden; pointer-events: auto; cursor: pointer; will-change: transform, opacity;
}
.tmi__toast[data-type="success"] { --tone: var(--tmi-accent); }
.tmi__toast[data-type="info"] { --tone: var(--text); }
.tmi__toast[data-type="warning"] { --tone: #F2B544; }
.tmi__toast[data-type="error"] { --tone: #FF5C5C; }
.tmi__icon { margin-top: 1px; }
.tmi__icon-ring, .tmi__icon-mark { fill: none; stroke: var(--tone); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
.tmi__toast-title { font-size: .9rem; font-weight: 500; letter-spacing: -0.01em; line-height: 1.3; }
.tmi__toast-msg { margin-top: 3px; font-size: .8rem; color: var(--muted); line-height: 1.45; }
.tmi__close { display: grid; place-items: center; width: 26px; height: 26px; margin: -4px -2px 0 0; border-radius: 50%; color: var(--muted); transition: background .2s, color .2s; }
.tmi__close:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
.tmi__close:focus-visible { outline: 2px solid var(--tmi-accent); outline-offset: 2px; }
.tmi__progress { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: var(--tone); transform-origin: left center; }
:root[data-theme="light"] .tmi__toast { box-shadow: 0 24px 60px -28px rgba(0,0,0,.35); }
@media (max-width: 767px) {
.tmi__stack { right: 12px; bottom: 12px; width: calc(100vw - 24px); }
}
/* reduced motion: toasts appear/disappear in place; only the timer hairline changes */
@media (prefers-reduced-motion: reduce) {
.tmi__toast { will-change: auto; }
}
// Toast Notification Micro-interaction — back.out entrance, Flip re-flow, DrawSVG icon, hairline timer.
gsap.registerPlugin(Flip, DrawSVGPlugin);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lifetime: 4, enterDuration: 0.5, overshoot: 1.4, slide: 40, demoSalvo: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const MAX_TOASTS = 4;
const EXIT = { duration: 0.35, ease: 'power2.in' };
const TYPES = {
success: { title: 'Order #4812 confirmed', msg: 'Receipt sent to the customer.', mark: 'M7.5 12.5l3 3 6-7' },
info: { title: 'Inventory synced', msg: '142 SKUs updated from the Oslo warehouse.', mark: 'M12 11v5.5M12 7.5v.5' },
warning: { title: 'Low stock: Linen Throw', msg: 'Only 3 units left — reorder point is 12.', mark: 'M12 7.5v5.5M12 16v.5' },
error: { title: 'Payment declined', msg: 'Card ending 4421 was refused by the issuer.', mark: 'M8.5 8.5l7 7M15.5 8.5l-7 7' },
};
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.tmi');
const stack = root.querySelector('.tmi__stack');
const tpl = document.getElementById('tmi-toast');
const triggers = root.querySelectorAll('.tmi__trigger');
let ctx, ac;
function build() {
ctx?.revert();
ac?.abort();
ac = new AbortController();
stack.replaceChildren();
root.style.setProperty('--knob-accent', K('accent'));
const LIFETIME = K('lifetime'); // seconds before auto-dismiss
const SLIDE = K('slide'); // px, entrance/exit x offset
const ENTER = { duration: K('enterDuration'), ease: `back.out(${K('overshoot')})` };
ctx = gsap.context(() => {
const spawn = (type) => {
const data = TYPES[type];
if (stack.children.length >= MAX_TOASTS) dismiss(stack.firstElementChild);
const el = tpl.content.firstElementChild.cloneNode(true);
el.dataset.type = type;
el.querySelector('.tmi__toast-title').textContent = data.title;
el.querySelector('.tmi__toast-msg').textContent = data.msg;
el.querySelector('.tmi__icon-mark').setAttribute('d', data.mark);
const bar = el.querySelector('.tmi__progress');
const icon = [el.querySelector('.tmi__icon-ring'), el.querySelector('.tmi__icon-mark')];
// 1. record where the existing toasts are, 2. mutate, 3. let Flip animate the shift
const state = Flip.getState(stack.children);
stack.appendChild(el);
if (!reduced) Flip.from(state, { duration: 0.5, ease: 'power3.out' });
if (reduced) {
gsap.set(el, { x: 0, opacity: 1, scale: 1 });
gsap.set(icon, { drawSVG: '100%' });
} else {
gsap.fromTo(el, { x: SLIDE, opacity: 0, scale: 0.96 }, { x: 0, opacity: 1, scale: 1, ...ENTER });
gsap.fromTo(icon, { drawSVG: '0%' }, { drawSVG: '100%', duration: 0.6, ease: 'power2.out', stagger: 0.12, delay: 0.15 });
}
// the hairline IS the timer
el._timer = gsap.fromTo(bar, { scaleX: 1 }, {
scaleX: 0, duration: LIFETIME, ease: 'none', delay: 0.3,
onComplete: () => dismiss(el),
});
el.addEventListener('pointerenter', () => el._timer.pause());
el.addEventListener('pointerleave', () => el._timer.resume());
el.addEventListener('click', () => dismiss(el));
};
const dismiss = (el) => {
if (!el || el._leaving) return;
el._leaving = true;
el._timer?.kill();
el.style.pointerEvents = 'none';
const remove = () => {
const others = [...stack.children].filter((c) => c !== el);
const state = Flip.getState(others);
el.remove();
if (!reduced) Flip.from(state, { duration: 0.4, ease: 'power3.out' });
};
if (reduced) remove();
else gsap.to(el, { x: SLIDE, opacity: 0, ...EXIT, onComplete: remove });
};
triggers.forEach((btn) => btn.addEventListener('click', () => spawn(btn.dataset.type), { signal: ac.signal }));
// a short demo salvo so the stack is never empty on first view
if (K('demoSalvo')) {
gsap.delayedCall(0.6, () => spawn('success'));
gsap.delayedCall(1.5, () => spawn('info'));
gsap.delayedCall(2.4, () => spawn('warning'));
}
});
}
build();
// Playground: rebuild (clears the stack, re-fires the salvo) with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { ctx?.revert(); ac?.abort(); stack.replaceChildren(); });