Archive an email: the row's height glides shut and the rest slide up
An email list where each message is an invisible slot with a visible card data-⚓️-fill on it and data-⚓️-transition=glide.
<main class="demo">
<header class="intro">
<h1>Archive and slide</h1>
<p>
Each email is an invisible slot in a plain list, with the visible card <code>data-⚓️-fill</code> on its slot and
<code>data-⚓️-transition="glide"</code>. Archiving only sets the slot's height to zero: the list reflows <em>instantly</em>,
so the archived card's height glides shut and every card below glides up into its new position.
The inbox panel behind them is anchored to the whole list too, so it shrinks with them.
The script flips one attribute per click.
</p>
</header>
<div class="controls">
<button type="button" id="undo" disabled>Undo</button>
<button type="button" id="reset" disabled>Restore all</button>
<output class="readout" id="readout" aria-live="polite"></output>
<label class="xray-toggle"><input type="checkbox" name="xray"> X-ray (show the slots)</label>
</div>
<div class="inbox" data-⚓️-root>
<div class="panel" aria-hidden="true"
data-⚓️-to="--list" data-⚓️-fill data-⚓️-transition="glide"></div>
<div class="list" data-⚓️-name="--list"></div>
</div>
</main>/* Demo styling only. Every card's position AND size come from data-⚓️-fill on its slot, and the glide
comes from data-⚓️-transition. An archived slot just gets height 0; the cards do the animating. */
:root {
color-scheme: dark;
--bg: oklch(16% 0.012 270);
--ink: oklch(94% 0.01 270);
--muted: oklch(70% 0.015 270);
--line: oklch(32% 0.02 270);
--accent: oklch(82% 0.13 190);
--row: 4.75rem;
--gap: 0.5rem;
--dur: 0.5s;
--ease: cubic-bezier(0.25, 1, 0.5, 1);
background: var(--bg);
color: var(--ink);
font: 400 1rem/1.5 system-ui, sans-serif;
}
body { margin: 0; }
.demo { max-width: 44rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2.5rem); display: grid; gap: 1.25rem; }
.intro h1 { margin: 0 0 0.35rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); max-width: 70ch; text-wrap: pretty; }
code { font: 0.88em ui-monospace, monospace; color: var(--accent); white-space: nowrap; }
/* ---------- controls ---------- */
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 0.9rem; }
.controls button {
padding: 0.4rem 0.8rem; border-radius: 0.6rem; border: 1px solid var(--line);
background: oklch(22% 0.015 270); color: var(--ink); font: 500 0.875rem system-ui, sans-serif; cursor: pointer;
}
.controls button:hover:not(:disabled) { background: oklch(28% 0.02 270); }
.controls button:disabled { opacity: 0.4; cursor: default; }
.controls :is(button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.readout { font: 500 0.85rem ui-monospace, monospace; color: var(--muted); }
.xray-toggle { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.875rem; color: var(--muted); cursor: pointer; margin-inline-start: auto; }
.xray-toggle input { accent-color: var(--accent); }
/* ---------- the list: invisible slots, instant reflow ---------- */
.inbox { padding: 0.5rem; }
.list { padding: 0.5rem; }
.slot { block-size: var(--row); margin-block-end: var(--gap); }
.slot:last-child { margin-block-end: 0; }
.slot[data-archived] { block-size: 0; margin-block-end: 0; }
.slot.empty { block-size: 0; margin: 0; }
.list:not(:has(.slot:not(.empty):not([data-archived]))) .empty { block-size: 6rem; }
/* the backing panel is anchored to the whole list */
.panel {
border-radius: 1rem;
background: oklch(19% 0.014 270);
box-shadow: 0 0 0 1px var(--line), 0 1.25rem 2.5rem -1rem oklch(0% 0 0 / 0.6);
}
/* ---------- the visible cards ---------- */
.card {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 0.75rem;
overflow: clip;
border-radius: 0.8rem;
background: oklch(24% 0.016 270);
box-shadow: 0 0 0 1px oklch(36% 0.02 270 / 0.7);
}
/* fixed-height content so the text doesn't reflow while the card's height closes */
.card { padding-inline: 0.9rem; align-content: start; }
.card > * { align-self: center; }
.avatar {
inline-size: 2.4rem; block-size: 2.4rem; border-radius: 50%;
display: grid; place-items: center; font-weight: 700; font-size: 0.95rem;
background: oklch(48% 0.12 var(--h)); color: oklch(97% 0.01 var(--h));
}
.text { min-inline-size: 0; display: grid; line-height: 1.3; }
.text b { font-weight: 600; font-size: 0.92rem; }
.text .subject { font-size: 0.88rem; }
.text .preview { font-size: 0.8rem; color: var(--muted); }
.text > * { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
time { font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.archive {
display: grid; place-items: center; inline-size: 2.4rem; block-size: 2.4rem;
border-radius: 0.6rem; border: 0; background: transparent; color: var(--muted); cursor: pointer;
}
.archive:hover { background: oklch(32% 0.03 190); color: var(--accent); }
.archive:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.archive svg { inline-size: 1.25rem; block-size: 1.25rem; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* the content row keeps its full height; the card is just a shrinking window onto it */
.card { grid-template-rows: var(--row); }
/* archived: the slot is already 0 tall, so the card glides shut; slide and fade it too */
.slot[data-archived] .card { opacity: 0; translate: 2.5rem 0; pointer-events: none; }
.empty .card {
grid-template-columns: 1fr; justify-items: center; text-align: center; color: var(--muted);
background: transparent; box-shadow: 0 0 0 1px var(--line); border-radius: 1rem; opacity: 1;
}
.slot.empty .card { opacity: 0; }
.list:not(:has(.slot:not(.empty):not([data-archived]))) .slot.empty .card { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
.card {
transition:
inset var(--dur) var(--ease), width var(--dur) var(--ease), height var(--dur) var(--ease),
translate var(--dur) var(--ease), opacity calc(var(--dur) * 0.6) ease;
}
.panel {
transition: inset var(--dur) var(--ease), width var(--dur) var(--ease), height var(--dur) var(--ease);
}
}
/* ---------- X-ray ---------- */
body:has([name="xray"]:checked) {
& .slot { outline: 1px dashed oklch(80% 0.14 190 / 0.8); outline-offset: -1px; }
& .card { opacity: 0.6; }
& .list { outline: 1px solid oklch(70% 0.14 20 / 0.7); }
}// The only thing a click does is set data-archived on a slot (CSS gives that slot height 0).
// Every card is data-⚓️-fill on its slot with glide, so the list reflows instantly and the cards follow.
const MAILS = [
["Priya Raman", "Design review notes", "Thanks for the walkthrough, a few thoughts on the spacing scale before we ship…", "9:41", 330],
["GitHub", "[anchored-auto-layout] PR #42 merged", "argyleink merged pull request #42 into main. View the diff and release notes…", "9:12", 260],
["Marcus Lee", "Lunch Thursday?", "I'm free around noon if you want to try the new ramen place on Pine, my treat…", "8:50", 20],
["Netlify", "Deploy succeeded for beer-dashboard", "Your site is live. Build time 18s. Production deploy from branch main…", "8:31", 175],
["Sierra", "Soccer pickup schedule", "Ollie's practice moved to 5:30 this week, and Link has a game Saturday morning…", "Yesterday", 345],
["Tomás Ortega", "Re: conference talk abstract", "Love the anchor positioning angle, can you cut it to 150 words by Friday?…", "Yesterday", 130],
["Chrome Platform", "What's new in Chrome 140", "Scroll-driven animations, new anchor() functions and more from the latest release…", "Mon", 210],
["Dr. Okafor's office", "Appointment reminder", "This is a reminder of your appointment on Tuesday at 2:15 PM. Reply C to confirm…", "Mon", 95],
["Robbie", "Episode outline", "Dropped the outline in the shared doc, let's talk guests on the next call…", "Sun", 280],
];
const list = document.querySelector(".list");
const undoBtn = document.getElementById("undo");
const resetBtn = document.getElementById("reset");
const readout = document.getElementById("readout");
const ICON = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5h18v4H3z"/><path d="M5 9v10h14V9"/><path d="M10 13h4"/></svg>`;
// innerHTML, not setAttribute: the ⚓️ attribute names aren't valid XML Names
list.innerHTML = MAILS.map(([who, subject, preview, when, hue], i) => `
<div class="slot" data-i="${i}" data-⚓️-name="--m${i}">
<article class="card" data-⚓️-to="--m${i}" data-⚓️-fill data-⚓️-transition="glide">
<span class="avatar" style="--h:${hue}" aria-hidden="true">${who[0]}</span>
<div class="text"><b>${who}</b><span class="subject">${subject}</span><span class="preview">${preview}</span></div>
<time>${when}</time>
<button class="archive" type="button" aria-label="Archive: ${subject}">${ICON}</button>
</article>
</div>`).join("") + `
<div class="slot empty" data-⚓️-name="--empty">
<div class="card" data-⚓️-to="--empty" data-⚓️-fill data-⚓️-transition="glide"><p>Inbox zero. Nothing left to archive.</p></div>
</div>`;
const slots = [...list.querySelectorAll(".slot:not(.empty)")];
const stack = []; // archive order, for Undo
function update(msg) {
const left = slots.filter((s) => !s.hasAttribute("data-archived")).length;
readout.textContent = msg ?? `${left} in inbox`;
undoBtn.disabled = resetBtn.disabled = stack.length === 0;
}
function archive(slot) {
const next = slots.slice(slots.indexOf(slot) + 1).find((s) => !s.hasAttribute("data-archived"))
?? [...slots].reverse().find((s) => s !== slot && !s.hasAttribute("data-archived"));
slot.toggleAttribute("data-archived", true);
slot.inert = true;
stack.push(slot);
update(`Archived: ${slot.querySelector(".subject").textContent}`);
// keep keyboard focus moving through the list instead of falling off the page
(next?.querySelector(".archive") ?? undoBtn).focus();
}
function restore(slot) {
slot.removeAttribute("data-archived");
slot.inert = false;
}
list.addEventListener("click", (e) => {
const btn = e.target.closest(".archive");
if (btn) archive(btn.closest(".slot"));
});
undoBtn.addEventListener("click", () => {
const s = stack.pop();
if (s) restore(s);
update();
});
resetBtn.addEventListener("click", () => {
while (stack.length) restore(stack.pop());
update();
});
update();data-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-todata-⚓️-transition