Corner toasts that pick a side
Toasts that stack below top-corner anchors and above bottom-corner anchors.
<header class="intro">
<h1>Corner toasts</h1>
<p>
Pick a corner and fire some toasts. Each corner anchor carries its own
<code>data-⚓️-side</code> (top corners stack <em>below</em>, bottom corners stack <em>above</em>)
and cross-axis <code>data-⚓️-align-*</code>. Toasts chain: the newest anchors to the corner, every
older one anchors to the toast in front of it. Switch corners and the whole stack
tweens across the screen (<code>data-⚓️-tween</code> + <code>morph</code>). JS only re-links anchor names.
</p>
</header>
<div class="controls">
<fieldset class="corners" aria-label="Toast corner">
<label><input type="radio" name="corner" value="--tl"><span>↖</span></label>
<label><input type="radio" name="corner" value="--tr"><span>↗</span></label>
<label><input type="radio" name="corner" value="--bl"><span>↙</span></label>
<label><input type="radio" name="corner" value="--br" checked><span>↘</span></label>
</fieldset>
<button class="primary" id="toast">Toast</button>
<button class="secondary" id="burst">Burst ×3</button>
<label class="xray-toggle"><input type="checkbox" id="xray"> X-ray</label>
</div>
<div class="screen" data-⚓️-root>
<div class="wallpaper" aria-hidden="true"></div>
<!-- Corner anchors. Where they live decides which way toasts stack. -->
<button class="corner" aria-label="Top left" data-⚓️-name="--tl" data-⚓️-pin-top data-⚓️-pin-leading
data-toast-side="below" data-toast-align="leading"></button>
<button class="corner" aria-label="Top right" data-⚓️-name="--tr" data-⚓️-pin-top data-⚓️-pin-trailing
data-toast-side="below" data-toast-align="trailing"></button>
<button class="corner" aria-label="Bottom left" data-⚓️-name="--bl" data-⚓️-pin-bottom data-⚓️-pin-leading
data-toast-side="above" data-toast-align="leading"></button>
<button class="corner" aria-label="Bottom right" data-⚓️-name="--br" data-⚓️-pin-bottom data-⚓️-pin-trailing
data-toast-side="above" data-toast-align="trailing"></button>
<!-- Newest toast first in DOM: an anchor must precede whatever anchors to it. -->
<div class="toasts" id="toasts" role="status" aria-live="polite"></div>
</div>
<template id="toast-tpl">
<div class="toast" data-⚓️-tween data-⚓️-transition="morph">
<div class="card">
<span class="icon" aria-hidden="true"></span>
<div class="text"><strong></strong><span></span></div>
<button class="close" aria-label="Dismiss">×</button>
<i class="timer" aria-hidden="true"></i>
</div>
</div>
</template>/* Demo styling only. Every position comes from data-⚓️-* attributes. */
:root {
--bg: oklch(16% 0.015 250);
--ink: oklch(93% 0.01 250);
--muted: oklch(68% 0.02 250);
--line: oklch(32% 0.02 250);
--accent: oklch(78% 0.15 165);
color-scheme: dark;
font: 400 1rem/1.5 system-ui, sans-serif;
color: var(--ink);
background: var(--bg);
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: clamp(1rem, 4vw, 2.5rem);
display: grid;
gap: 1.25rem;
justify-items: center;
}
.intro { max-width: 64ch; }
.intro h1 { margin: 0 0 0.4rem; font-size: 1.4rem; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); }
code { font: 0.85em ui-monospace, monospace; color: var(--accent); white-space: nowrap; }
/* ---------- controls ---------- */
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.corners {
display: grid;
grid-template-columns: repeat(2, 2.2rem);
gap: 0.25rem;
margin: 0;
padding: 0.25rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
}
.corners label { display: grid; cursor: pointer; }
.corners input { position: absolute; opacity: 0; pointer-events: none; }
.corners span {
display: grid;
place-items: center;
block-size: 2.2rem;
border-radius: 0.45rem;
color: var(--muted);
transition: background 0.2s, color 0.2s;
}
.corners input:checked + span { background: var(--accent); color: var(--bg); font-weight: 700; }
.corners input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
.primary, .secondary {
font: 600 0.95rem system-ui;
padding: 0.6rem 1.1rem;
border-radius: 0.6rem;
cursor: pointer;
}
.primary { border: 0; background: var(--accent); color: var(--bg); }
.secondary { border: 1px solid var(--line); background: none; color: var(--ink); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.xray-toggle {
display: inline-flex;
gap: 0.4rem;
align-items: center;
font: 500 0.85rem ui-monospace, monospace;
padding: 0.4rem 0.75rem;
border: 1px solid var(--line);
border-radius: 99px;
cursor: pointer;
}
/* ---------- the screen ---------- */
.screen {
width: min(100%, 60rem);
block-size: clamp(30rem, 62vh, 38rem);
border: 1px solid var(--line);
border-radius: 1.1rem;
overflow: clip;
background: oklch(20% 0.02 250);
}
.wallpaper {
block-size: 100%;
background:
radial-gradient(40% 50% at 20% 25%, oklch(45% 0.12 280 / 0.55), transparent 70%),
radial-gradient(45% 55% at 80% 75%, oklch(50% 0.12 165 / 0.4), transparent 70%),
radial-gradient(30% 35% at 70% 20%, oklch(55% 0.13 30 / 0.3), transparent 70%);
}
/* Corner anchors: small dots, pinned by the library, nudged in by margin. */
.corner {
margin: 1rem;
inline-size: 0.9rem;
block-size: 0.9rem;
padding: 0;
border-radius: 50%;
border: 2px solid oklch(80% 0.02 250 / 0.5);
background: none;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, scale 0.2s;
}
.corner:hover { scale: 1.3; }
body:has(input[value="--tl"]:checked) .corner[data-⚓️-name="--tl"],
body:has(input[value="--tr"]:checked) .corner[data-⚓️-name="--tr"],
body:has(input[value="--bl"]:checked) .corner[data-⚓️-name="--bl"],
body:has(input[value="--br"]:checked) .corner[data-⚓️-name="--br"] {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 0 6px oklch(78% 0.15 165 / 0.2);
}
/* ---------- toasts ---------- */
/* The .toast is a transparent frame whose padding is the gap to its anchor
(corner dot or the toast in front), so no side-specific margins exist
and every re-link is a pure inset/translate tween. */
.toast {
inline-size: min(19rem, calc(100vw - 4rem));
padding: 0.3rem 0;
}
.card {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: start;
gap: 0.75rem;
padding: 0.8rem 0.8rem 0.9rem;
border-radius: 0.85rem;
background: oklch(26% 0.02 250 / 0.92);
backdrop-filter: blur(12px);
border: 1px solid oklch(40% 0.02 250);
box-shadow: 0 14px 30px -14px oklch(5% 0 0 / 0.8);
overflow: hidden;
transition: opacity 0.2s ease, scale 0.2s ease;
}
.toast.leaving .card { opacity: 0; scale: 0.92; }
.icon {
display: grid;
place-items: center;
inline-size: 2rem;
block-size: 2rem;
border-radius: 0.55rem;
background: oklch(78% 0.15 165 / 0.15);
color: var(--accent);
font-weight: 700;
}
.text { display: grid; line-height: 1.3; }
.text strong { font-size: 0.95rem; }
.text span { font-size: 0.85rem; color: var(--muted); }
.close {
border: 0;
background: none;
color: var(--muted);
font-size: 1.2rem;
line-height: 1;
padding: 0.1rem 0.3rem;
border-radius: 0.3rem;
cursor: pointer;
}
.close:hover { color: var(--ink); background: oklch(35% 0.02 250); }
/* Auto-dismiss: the bar's animationend is the timer; hovering pauses it. */
.timer {
position: absolute;
inset-inline: 0;
inset-block-end: 0;
block-size: 3px;
background: var(--accent);
transform-origin: left;
animation: drain 6s linear forwards;
}
.timer:dir(rtl) { transform-origin: right; }
.toasts:hover .timer { animation-play-state: paused; }
@keyframes drain { to { scale: 0 1; } }
/* ---------- X-ray ---------- */
.xray [data-⚓️-name] { outline: 2px dashed oklch(70% 0.2 25); outline-offset: 2px; }
.xray .toast::after {
content: "to=" attr(data-⚓️-to) " side=" attr(data-⚓️-side);
position: absolute;
inset-block-start: 0;
inset-inline-end: 0.5rem;
translate: 0 -40%;
padding: 0.05rem 0.4rem;
border-radius: 0.3rem;
font: 600 0.66rem ui-monospace, monospace;
background: oklch(70% 0.2 25);
color: white;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.card { transition: opacity 0.15s ease; }
.toast.leaving .card { scale: 1; }
}// State only: a newest-first list of toasts and the chosen corner. relink()
// rewrites anchor names/sides; the library + browser place and tween them.
const layer = document.getElementById("toasts");
const tpl = document.getElementById("toast-tpl");
const MAX = 4;
const MESSAGES = [
["✓", "Saved", "All changes synced."],
["↑", "Upload complete", "3 files, 4.2 MB."],
["⧉", "Link copied", "Paste it anywhere."],
["✦", "New comment", "Mara replied on “Q3 plan”."],
["↺", "Restored", "Version from 10:42 is back."],
["☾", "Focus on", "Notifications muted for 1h."],
];
let stack = []; // newest first
let n = 0;
const corner = () => document.querySelector('input[name="corner"]:checked').value;
function relink() {
const c = document.querySelector(`[data-⚓️-name="${corner()}"]`).dataset;
const align = "⚓️Align" + c.toastAlign[0].toUpperCase() + c.toastAlign.slice(1);
stack.forEach((t, i) => {
t.dataset["⚓️To"] = i === 0 ? corner() : stack[i - 1].dataset["⚓️Name"];
t.dataset["⚓️Side"] = c.toastSide;
delete t.dataset["⚓️AlignLeading"];
delete t.dataset["⚓️AlignTrailing"];
t.dataset[align] = "";
});
}
function toast() {
const [icon, title, body] = MESSAGES[n % MESSAGES.length];
const t = tpl.content.firstElementChild.cloneNode(true);
t.dataset["⚓️Name"] = `--toast-${++n}`;
t.dataset["⚓️MorphFrom"] = corner(); // grow out of the corner dot
t.querySelector(".icon").textContent = icon;
t.querySelector("strong").textContent = title;
t.querySelector(".text span").textContent = body;
t.querySelector(".close").onclick = () => dismiss(t);
t.querySelector(".timer").addEventListener("animationend", () => dismiss(t));
stack.unshift(t);
relink();
layer.prepend(t);
stack.slice(MAX).forEach(dismiss);
}
function dismiss(t) {
if (t.classList.contains("leaving")) return;
t.classList.add("leaving");
t.querySelector(".card").addEventListener("transitionend", () => {
stack = stack.filter((x) => x !== t);
t.remove();
relink(); // the toast behind it re-anchors and glides into the gap
}, { once: true });
}
document.getElementById("toast").onclick = toast;
document.getElementById("burst").onclick = () => [0, 180, 360].forEach((d) => setTimeout(toast, d));
document.querySelectorAll('input[name="corner"]').forEach((r) => (r.onchange = relink));
document.querySelectorAll(".corner").forEach((b) => (b.onclick = () => {
document.querySelector(`input[value="${b.dataset["⚓️Name"]}"]`).checked = true;
relink();
}));
document.getElementById("xray").onchange = (e) => document.body.classList.toggle("xray", e.target.checked);
toast();data-⚓️-namedata-⚓️-pin-bottomdata-⚓️-pin-leadingdata-⚓️-pin-topdata-⚓️-pin-trailingdata-⚓️-rootdata-⚓️-sidedata-⚓️-transitiondata-⚓️-tween