Patterns Medium
Animate Presence
Elements that animate in AND out of the DOM with smooth enter/exit transitions. Add and remove list items with fade+slide animations that play before DOM removal.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.demo {
width: min(480px, 100%);
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.demo-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.demo-title {
font-size: 1.25rem;
font-weight: 700;
color: #f4f4f5;
}
.demo-subtitle {
font-size: 0.8rem;
color: #52525b;
margin-top: 0.25rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 1rem;
font-size: 0.8rem;
font-weight: 600;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background 0.2s, transform 0.15s;
}
.btn:active {
transform: scale(0.96);
}
.btn-add {
background: #6d28d9;
color: #f4f4f5;
}
.btn-add:hover {
background: #7c3aed;
}
.btn-clear {
background: rgba(255, 255, 255, 0.06);
color: #a1a1aa;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.btn-clear:hover {
background: rgba(255, 255, 255, 0.1);
}
.controls {
display: flex;
gap: 0.5rem;
}
/* ── Item list ── */
.item-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-height: 60px;
}
.item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0.75rem;
animation: fadeSlideIn 0.35s cubic-bezier(0.22, 1, 0.36, 1) forwards;
opacity: 0;
}
.item.exiting {
animation: fadeSlideOut 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
.item-icon {
width: 36px;
height: 36px;
border-radius: 0.5rem;
display: grid;
place-items: center;
font-size: 1rem;
flex-shrink: 0;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-name {
font-size: 0.875rem;
font-weight: 600;
color: #e4e4e7;
}
.item-time {
font-size: 0.7rem;
color: #52525b;
margin-top: 0.15rem;
}
.btn-remove {
width: 28px;
height: 28px;
border-radius: 0.375rem;
border: none;
background: rgba(255, 255, 255, 0.04);
color: #71717a;
cursor: pointer;
display: grid;
place-items: center;
font-size: 1rem;
transition: background 0.15s, color 0.15s;
flex-shrink: 0;
}
.btn-remove:hover {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #3f3f46;
font-size: 0.85rem;
}
/* ── Keyframes ── */
@keyframes fadeSlideIn {
from {
opacity: 0;
transform: translateY(-12px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeSlideOut {
from {
opacity: 1;
transform: translateY(0) scale(1);
}
to {
opacity: 0;
transform: translateY(12px) scale(0.96);
}
}(function () {
"use strict";
const list = document.getElementById("item-list");
const emptyState = document.getElementById("empty-state");
const btnAdd = document.getElementById("btn-add");
const btnClear = document.getElementById("btn-clear");
const icons = ["✦", "◆", "●", "▲", "★", "◉", "⬟", "⬡"];
const colors = [
"rgba(109,40,217,0.25)",
"rgba(59,130,246,0.25)",
"rgba(16,185,129,0.25)",
"rgba(245,158,11,0.25)",
"rgba(239,68,68,0.25)",
"rgba(236,72,153,0.25)",
"rgba(14,165,233,0.25)",
"rgba(168,85,247,0.25)",
];
const borderColors = [
"rgba(109,40,217,0.5)",
"rgba(59,130,246,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(239,68,68,0.5)",
"rgba(236,72,153,0.5)",
"rgba(14,165,233,0.5)",
"rgba(168,85,247,0.5)",
];
const names = [
"Design tokens updated",
"New component merged",
"Build pipeline passed",
"Sprint review scheduled",
"Pull request approved",
"Test coverage improved",
"Deployment complete",
"Security audit passed",
"Performance optimized",
"Documentation updated",
"API endpoint added",
"Database migrated",
];
let counter = 0;
function updateEmpty() {
const hasItems = list.querySelectorAll(".item:not(.exiting)").length > 0;
emptyState.style.display = hasItems ? "none" : "block";
}
function addItem() {
const i = counter % icons.length;
counter++;
const id = Date.now();
const name = names[Math.floor(Math.random() * names.length)];
const now = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const li = document.createElement("li");
li.className = "item";
li.dataset.id = id;
li.innerHTML = `
<div class="item-icon" style="background:${colors[i]};border:1px solid ${borderColors[i]};color:${borderColors[i].replace("0.5", "1")}">
${icons[i]}
</div>
<div class="item-content">
<div class="item-name">${name}</div>
<div class="item-time">${now}</div>
</div>
<button class="btn-remove" title="Remove">×</button>
`;
list.prepend(li);
updateEmpty();
}
function removeItem(li) {
li.classList.add("exiting");
li.addEventListener(
"animationend",
() => {
li.remove();
updateEmpty();
},
{ once: true }
);
}
list.addEventListener("click", (e) => {
const btn = e.target.closest(".btn-remove");
if (!btn) return;
const li = btn.closest(".item");
if (li) removeItem(li);
});
btnAdd.addEventListener("click", addItem);
btnClear.addEventListener("click", () => {
const items = list.querySelectorAll(".item:not(.exiting)");
items.forEach((li, i) => {
setTimeout(() => removeItem(li), i * 50);
});
});
// Start with a few items
for (let i = 0; i < 3; i++) {
setTimeout(() => addItem(), i * 120);
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Animate Presence</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<div>
<div class="demo-header">
<div>
<h2 class="demo-title">Animate Presence</h2>
<p class="demo-subtitle">Items animate in and out of the DOM</p>
</div>
<div class="controls">
<button class="btn btn-clear" id="btn-clear">Clear all</button>
<button class="btn btn-add" id="btn-add">+ Add item</button>
</div>
</div>
</div>
<ul class="item-list" id="item-list">
<!-- items will be added dynamically -->
</ul>
<p class="empty-state" id="empty-state">Click "+ Add item" to begin</p>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
interface Item {
id: number;
icon: string;
color: string;
border: string;
name: string;
time: string;
}
const icons = ["✦", "◆", "●", "▲", "★", "◉", "⬟", "⬡"];
const colors = [
"rgba(109,40,217,0.25)",
"rgba(59,130,246,0.25)",
"rgba(16,185,129,0.25)",
"rgba(245,158,11,0.25)",
"rgba(239,68,68,0.25)",
"rgba(236,72,153,0.25)",
"rgba(14,165,233,0.25)",
"rgba(168,85,247,0.25)",
];
const borderColors = [
"rgba(109,40,217,0.5)",
"rgba(59,130,246,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(239,68,68,0.5)",
"rgba(236,72,153,0.5)",
"rgba(14,165,233,0.5)",
"rgba(168,85,247,0.5)",
];
const names = [
"Design tokens updated",
"New component merged",
"Build pipeline passed",
"Sprint review scheduled",
"Pull request approved",
"Test coverage improved",
"Deployment complete",
"Security audit passed",
];
function AnimatePresenceItem({
item,
onRemove,
}: {
item: Item;
onRemove: (id: number) => void;
}) {
const ref = useRef<HTMLLIElement>(null);
const [state, setState] = useState<"entering" | "present" | "exiting">("entering");
useEffect(() => {
if (state === "entering") {
const t = setTimeout(() => setState("present"), 350);
return () => clearTimeout(t);
}
}, [state]);
const handleRemove = () => {
setState("exiting");
const el = ref.current;
if (el) {
el.addEventListener("animationend", () => onRemove(item.id), { once: true });
}
};
const animStyle: React.CSSProperties =
state === "entering"
? { animation: "fadeSlideIn 0.35s cubic-bezier(0.22,1,0.36,1) forwards", opacity: 0 }
: state === "exiting"
? { animation: "fadeSlideOut 0.3s cubic-bezier(0.22,1,0.36,1) forwards" }
: {};
return (
<li
ref={ref}
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1rem",
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "0.75rem",
...animStyle,
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: "0.5rem",
display: "grid",
placeItems: "center",
fontSize: "1rem",
flexShrink: 0,
background: item.color,
border: `1px solid ${item.border}`,
color: item.border.replace("0.5", "1"),
}}
>
{item.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "0.875rem", fontWeight: 600, color: "#e4e4e7" }}>{item.name}</div>
<div style={{ fontSize: "0.7rem", color: "#52525b", marginTop: "0.15rem" }}>
{item.time}
</div>
</div>
<button
onClick={handleRemove}
style={{
width: 28,
height: 28,
borderRadius: "0.375rem",
border: "none",
background: "rgba(255,255,255,0.04)",
color: "#71717a",
cursor: "pointer",
display: "grid",
placeItems: "center",
fontSize: "1rem",
}}
>
×
</button>
</li>
);
}
export default function AnimatePresence() {
const [items, setItems] = useState<Item[]>([]);
const counterRef = useRef(0);
const addItem = useCallback(() => {
const i = counterRef.current % icons.length;
counterRef.current++;
const now = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setItems((prev) => [
{
id: Date.now(),
icon: icons[i],
color: colors[i],
border: borderColors[i],
name: names[Math.floor(Math.random() * names.length)],
time: now,
},
...prev,
]);
}, []);
const removeItem = useCallback((id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const clearAll = () => {
setItems([]);
};
return (
<div
style={{
minHeight: "100vh",
background: "#0a0a0a",
display: "grid",
placeItems: "center",
padding: "2rem",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e4e4e7",
}}
>
<style>{`
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fadeSlideOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(12px) scale(0.96); }
}
`}</style>
<div
style={{
width: "min(480px, 100%)",
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<h2 style={{ fontSize: "1.25rem", fontWeight: 700, color: "#f4f4f5" }}>
Animate Presence
</h2>
<p style={{ fontSize: "0.8rem", color: "#52525b", marginTop: "0.25rem" }}>
Items animate in and out of the DOM
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button
onClick={clearAll}
style={{
padding: "0.5rem 1rem",
fontSize: "0.8rem",
fontWeight: 600,
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "0.5rem",
cursor: "pointer",
background: "rgba(255,255,255,0.06)",
color: "#a1a1aa",
}}
>
Clear all
</button>
<button
onClick={addItem}
style={{
padding: "0.5rem 1rem",
fontSize: "0.8rem",
fontWeight: 600,
border: "none",
borderRadius: "0.5rem",
cursor: "pointer",
background: "#6d28d9",
color: "#f4f4f5",
}}
>
+ Add item
</button>
</div>
</div>
<ul
style={{
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: "0.5rem",
minHeight: 60,
}}
>
{items.map((item) => (
<AnimatePresenceItem key={item.id} item={item} onRemove={removeItem} />
))}
{items.length === 0 && (
<p
style={{
textAlign: "center",
padding: "2rem",
color: "#3f3f46",
fontSize: "0.85rem",
}}
>
Click "+ Add item" to begin
</p>
)}
</ul>
</div>
</div>
);
}<script setup>
import { ref } from "vue";
const icons = ["✦", "◆", "●", "▲", "★", "◉", "⬟", "⬡"];
const colors = [
"rgba(109,40,217,0.25)",
"rgba(59,130,246,0.25)",
"rgba(16,185,129,0.25)",
"rgba(245,158,11,0.25)",
"rgba(239,68,68,0.25)",
"rgba(236,72,153,0.25)",
"rgba(14,165,233,0.25)",
"rgba(168,85,247,0.25)",
];
const borderColors = [
"rgba(109,40,217,0.5)",
"rgba(59,130,246,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(239,68,68,0.5)",
"rgba(236,72,153,0.5)",
"rgba(14,165,233,0.5)",
"rgba(168,85,247,0.5)",
];
const names = [
"Design tokens updated",
"New component merged",
"Build pipeline passed",
"Sprint review scheduled",
"Pull request approved",
"Test coverage improved",
"Deployment complete",
"Security audit passed",
];
const items = ref([]);
let counter = 0;
function addItem() {
const i = counter % icons.length;
counter++;
const now = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
items.value = [
{
id: Date.now(),
icon: icons[i],
color: colors[i],
border: borderColors[i],
name: names[Math.floor(Math.random() * names.length)],
time: now,
state: "entering",
},
...items.value,
];
setTimeout(() => {
items.value = items.value.map((item) =>
item.state === "entering" ? { ...item, state: "present" } : item
);
}, 350);
}
function removeItem(id) {
items.value = items.value.map((item) => (item.id === id ? { ...item, state: "exiting" } : item));
setTimeout(() => {
items.value = items.value.filter((item) => item.id !== id);
}, 300);
}
function clearAll() {
items.value = [];
}
function getAnimStyle(state) {
if (state === "entering")
return { animation: "fadeSlideIn 0.35s cubic-bezier(0.22,1,0.36,1) forwards", opacity: 0 };
if (state === "exiting")
return { animation: "fadeSlideOut 0.3s cubic-bezier(0.22,1,0.36,1) forwards" };
return {};
}
</script>
<template>
<div
style="min-height: 100vh; background: #0a0a0a; display: grid; place-items: center; padding: 2rem; font-family: system-ui, -apple-system, sans-serif; color: #e4e4e7;"
>
<div style="width: min(480px, 100%); display: flex; flex-direction: column; gap: 1.25rem;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f4f4f5;">Animate Presence</h2>
<p style="font-size: 0.8rem; color: #52525b; margin-top: 0.25rem;">Items animate in and out of the DOM</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
@click="clearAll"
style="padding: 0.5rem 1rem; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.08); border-radius: 0.5rem; cursor: pointer; background: rgba(255,255,255,0.06); color: #a1a1aa;"
>
Clear all
</button>
<button
@click="addItem"
style="padding: 0.5rem 1rem; font-size: 0.8rem; font-weight: 600; border: none; border-radius: 0.5rem; cursor: pointer; background: #6d28d9; color: #f4f4f5;"
>
+ Add item
</button>
</div>
</div>
<ul style="list-style: none; display: flex; flex-direction: column; gap: 0.5rem; min-height: 60px; padding: 0; margin: 0;">
<li
v-for="item in items"
:key="item.id"
:style="{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.875rem 1rem',
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: '0.75rem',
...getAnimStyle(item.state),
}"
>
<div
:style="{
width: '36px',
height: '36px',
borderRadius: '0.5rem',
display: 'grid',
placeItems: 'center',
fontSize: '1rem',
flexShrink: 0,
background: item.color,
border: `1px solid ${item.border}`,
color: item.border.replace('0.5', '1'),
}"
>
{{ item.icon }}
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 0.875rem; font-weight: 600; color: #e4e4e7;">{{ item.name }}</div>
<div style="font-size: 0.7rem; color: #52525b; margin-top: 0.15rem;">{{ item.time }}</div>
</div>
<button
@click="removeItem(item.id)"
style="width: 28px; height: 28px; border-radius: 0.375rem; border: none; background: rgba(255,255,255,0.04); color: #71717a; cursor: pointer; display: grid; place-items: center; font-size: 1rem;"
>
×
</button>
</li>
<p v-if="items.length === 0" style="text-align: center; padding: 2rem; color: #3f3f46; font-size: 0.85rem;">
Click "+ Add item" to begin
</p>
</ul>
</div>
</div>
</template>
<style scoped>
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fadeSlideOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(12px) scale(0.96); }
}
</style><script>
const icons = ["✦", "◆", "●", "▲", "★", "◉", "⬟", "⬡"];
const colors = [
"rgba(109,40,217,0.25)",
"rgba(59,130,246,0.25)",
"rgba(16,185,129,0.25)",
"rgba(245,158,11,0.25)",
"rgba(239,68,68,0.25)",
"rgba(236,72,153,0.25)",
"rgba(14,165,233,0.25)",
"rgba(168,85,247,0.25)",
];
const borderColors = [
"rgba(109,40,217,0.5)",
"rgba(59,130,246,0.5)",
"rgba(16,185,129,0.5)",
"rgba(245,158,11,0.5)",
"rgba(239,68,68,0.5)",
"rgba(236,72,153,0.5)",
"rgba(14,165,233,0.5)",
"rgba(168,85,247,0.5)",
];
const names = [
"Design tokens updated",
"New component merged",
"Build pipeline passed",
"Sprint review scheduled",
"Pull request approved",
"Test coverage improved",
"Deployment complete",
"Security audit passed",
];
let items = [];
let counter = 0;
function addItem() {
const i = counter % icons.length;
counter++;
const now = new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
items = [
{
id: Date.now(),
icon: icons[i],
color: colors[i],
border: borderColors[i],
name: names[Math.floor(Math.random() * names.length)],
time: now,
state: "entering",
},
...items,
];
setTimeout(() => {
items = items.map((item) => (item.state === "entering" ? { ...item, state: "present" } : item));
}, 350);
}
function removeItem(id) {
items = items.map((item) => (item.id === id ? { ...item, state: "exiting" } : item));
setTimeout(() => {
items = items.filter((item) => item.id !== id);
}, 300);
}
function clearAll() {
items = [];
}
function getAnimStyle(state) {
if (state === "entering")
return "animation: fadeSlideIn 0.35s cubic-bezier(0.22,1,0.36,1) forwards; opacity: 0;";
if (state === "exiting")
return "animation: fadeSlideOut 0.3s cubic-bezier(0.22,1,0.36,1) forwards;";
return "";
}
</script>
<div
style="min-height: 100vh; background: #0a0a0a; display: grid; place-items: center; padding: 2rem; font-family: system-ui, -apple-system, sans-serif; color: #e4e4e7;"
>
<div style="width: min(480px, 100%); display: flex; flex-direction: column; gap: 1.25rem;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h2 style="font-size: 1.25rem; font-weight: 700; color: #f4f4f5;">Animate Presence</h2>
<p style="font-size: 0.8rem; color: #52525b; margin-top: 0.25rem;">Items animate in and out of the DOM</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<button
on:click={clearAll}
style="padding: 0.5rem 1rem; font-size: 0.8rem; font-weight: 600; border: 1px solid rgba(255,255,255,0.08); border-radius: 0.5rem; cursor: pointer; background: rgba(255,255,255,0.06); color: #a1a1aa;"
>
Clear all
</button>
<button
on:click={addItem}
style="padding: 0.5rem 1rem; font-size: 0.8rem; font-weight: 600; border: none; border-radius: 0.5rem; cursor: pointer; background: #6d28d9; color: #f4f4f5;"
>
+ Add item
</button>
</div>
</div>
<ul style="list-style: none; display: flex; flex-direction: column; gap: 0.5rem; min-height: 60px; padding: 0; margin: 0;">
{#each items as item (item.id)}
<li
style="display: flex; align-items: center; gap: 0.75rem; padding: 0.875rem 1rem; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 0.75rem; {getAnimStyle(item.state)}"
>
<div
style="width: 36px; height: 36px; border-radius: 0.5rem; display: grid; place-items: center; font-size: 1rem; flex-shrink: 0; background: {item.color}; border: 1px solid {item.border}; color: {item.border.replace('0.5', '1')};"
>
{item.icon}
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 0.875rem; font-weight: 600; color: #e4e4e7;">{item.name}</div>
<div style="font-size: 0.7rem; color: #52525b; margin-top: 0.15rem;">{item.time}</div>
</div>
<button
on:click={() => removeItem(item.id)}
style="width: 28px; height: 28px; border-radius: 0.375rem; border: none; background: rgba(255,255,255,0.04); color: #71717a; cursor: pointer; display: grid; place-items: center; font-size: 1rem;"
>
×
</button>
</li>
{/each}
{#if items.length === 0}
<p style="text-align: center; padding: 2rem; color: #3f3f46; font-size: 0.85rem;">
Click "+ Add item" to begin
</p>
{/if}
</ul>
</div>
</div>
<style>
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-12px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fadeSlideOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(12px) scale(0.96); }
}
</style>Animate Presence
Demonstrates elements that animate both in and out of the DOM. Unlike simple CSS transitions that only handle enter states, this pattern plays a full exit animation before removing the element.
How it works
- Enter animation — newly added items get a
fadeSlideInkeyframe that runs on mount - Exit animation — before removal, the
fadeSlideOutkeyframe plays; ananimationendlistener then removes the node - The vanilla JS version manages a simple item list with add/remove buttons
- The React version wraps children in an
AnimatePresencecomponent that tracks mounting/unmounting
Use cases
- Toast / notification stacks
- Dynamic list items (todos, tags, chat messages)
- Modal and dialog open/close
- Accordion expand/collapse content