Documentation menu
Recipes
List and feedback recipes
Lists that enter and leave, badges, error shakes, checkmarks, loaders, progress and counters. Each recipe has the markup and the code it needs, and every example runs against the published @animaly/dom.
On this page
List enter
Stagger items in with one call. Each element gets its own delay from stagger.
<ul class="feed" style="display:grid;gap:8px;padding:0;list-style:none">
<li class="feed-item">First</li>
<li class="feed-item">Second</li>
<li class="feed-item">Third</li>
<li class="feed-item">Fourth</li>
<li class="feed-item">Fifth</li>
</ul>import { animate, stagger } from "@animaly/dom";
animate(
".feed-item",
{ y: [20, 0], opacity: [0, 1] },
{ type: "spring", stiffness: 260, damping: 24, delay: stagger(0.05) },
);List item removal
Slide the item out, collapse its height, then remove it from the DOM.
<ul class="todos" style="padding:0;list-style:none">
<li class="todo" style="overflow:hidden">Write docs <button class="remove">Remove</button></li>
<li class="todo" style="overflow:hidden">Ship 0.1 <button class="remove">Remove</button></li>
<li class="todo" style="overflow:hidden">Benchmark <button class="remove">Remove</button></li>
</ul>import { animate } from "@animaly/dom";
document.querySelector(".todos")!.addEventListener("click", async (event) => {
const button = (event.target as Element).closest(".remove");
if (!button) return;
const item = button.closest<HTMLElement>(".todo")!;
await animate(item, { opacity: 0, x: -24 }, { duration: 0.18 });
await animate(item, { height: 0, marginTop: 0, marginBottom: 0 }, { duration: 0.2 });
item.remove();
});
document.querySelector<HTMLButtonElement>(".remove")!.click();Notification badge
A three-value keyframe pops the badge whenever the count changes.
<button class="inbox" style="position:relative">Inbox
<span class="badge" style="position:absolute;top:-6px;right:-10px;min-width:18px;padding:0 5px;border-radius:999px;background:#ec4899;color:#fff;font-size:11px;text-align:center">3</span>
</button>import { animate } from "@animaly/dom";
const badge = document.querySelector<HTMLElement>(".badge")!;
export function setCount(count: number): void {
badge.textContent = String(count);
animate(badge, { scale: [1, 1.35, 1] }, { duration: 0.35, ease: "easeOut" });
}
setCount(4);Like button with a burst
Particles fly out in a circle and remove themselves when their animation finishes.
<button class="like" aria-pressed="false" aria-label="Like" style="position:relative;font-size:28px;background:none;border:0">♥</button>import { animate } from "@animaly/dom";
const like = document.querySelector<HTMLButtonElement>(".like")!;
function burst(): void {
const particles = Array.from({ length: 8 }, () => {
const dot = document.createElement("span");
dot.style.cssText = "position:absolute;left:50%;top:50%;width:6px;height:6px;margin:-3px;border-radius:50%;background:#ec4899;pointer-events:none";
like.append(dot);
return dot;
});
particles.forEach((dot, index) => {
const angle = (index / particles.length) * Math.PI * 2;
animate(dot, { x: Math.cos(angle) * 36, y: Math.sin(angle) * 36, opacity: [1, 0], scale: [1, 0.4] }, { duration: 0.5, ease: "easeOut" })
.then(() => dot.remove());
});
}
like.addEventListener("click", () => {
const liked = like.getAttribute("aria-pressed") !== "true";
like.setAttribute("aria-pressed", String(liked));
like.style.color = liked ? "#ec4899" : "";
animate(like, { scale: [0.8, 1.2, 1] }, { duration: 0.4 });
if (liked) burst();
});
like.click();Shake an invalid field
Keyframes on x shake the input while its border flashes red.
<form class="signup">
<input class="email" type="email" required placeholder="you@example.com">
<button>Sign up</button>
</form>import { animate } from "@animaly/dom";
const form = document.querySelector<HTMLFormElement>(".signup")!;
const input = document.querySelector<HTMLInputElement>(".email")!;
// The browser fires "invalid" instead of submitting when a field fails validation.
input.addEventListener("invalid", () => {
animate(input, { x: [0, -10, 10, -6, 6, -2, 0] }, { duration: 0.45 });
animate(input, { borderColor: ["#dc2626", "#dc2626", "#d4d4d4"] }, { duration: 1.2 });
});
form.requestSubmit();Success checkmark
Draw an SVG circle and tick in sequence with pathLength, then pulse the icon.
<svg class="check" viewBox="0 0 52 52" width="52" height="52" fill="none" stroke="#16a34a" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<circle class="check-circle" cx="26" cy="26" r="23" />
<path class="check-mark" d="M15 27l7 7 15-15" />
</svg>import { animate } from "@animaly/dom";
animate([
[".check-circle", { pathLength: [0, 1] }, { duration: 0.4, ease: "easeOut" }],
[".check-mark", { pathLength: [0, 1] }, { duration: 0.3, ease: "easeOut" }],
[".check", { scale: [1, 1.08, 1] }, { duration: 0.25 }],
]);Loading dots
Three dots bounce forever with staggered delays. Keep the controls and call stop() when loading ends.
<div class="loader" role="status" aria-label="Loading" style="display:flex;gap:6px">
<span class="loader-dot" style="width:8px;height:8px;border-radius:50%;background:#7c3aed"></span>
<span class="loader-dot" style="width:8px;height:8px;border-radius:50%;background:#7c3aed"></span>
<span class="loader-dot" style="width:8px;height:8px;border-radius:50%;background:#7c3aed"></span>
</div>import { animate, stagger } from "@animaly/dom";
const loading = animate(
".loader-dot",
{ y: [0, -8, 0], opacity: [0.4, 1, 0.4] },
{ duration: 0.6, repeat: Infinity, delay: stagger(0.12) },
);
// Call when the data arrives.
export function done(): void {
loading.stop();
}Skeleton shimmer
Animate backgroundPosition across a wide gradient. Skip it for readers who prefer reduced motion.
<div class="skeleton" style="height:16px;width:240px;border-radius:8px;background:linear-gradient(90deg,#eee 25%,#f8f8f8 50%,#eee 75%);background-size:200% 100%;background-position:100% 0"></div>import { animate } from "@animaly/dom";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
animate(".skeleton", { backgroundPosition: ["100% 0%", "-100% 0%"] }, { duration: 1.4, ease: "linear", repeat: Infinity });
}Progress bar
Spring the fill with scaleX instead of width, so it never triggers layout.
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" style="height:6px;border-radius:3px;background:#eee;overflow:hidden">
<div class="progress-fill" style="height:100%;background:#7c3aed;transform-origin:left;transform:scaleX(0)"></div>
</div>import { animate } from "@animaly/dom";
const bar = document.querySelector<HTMLElement>(".progress")!;
const fill = document.querySelector<HTMLElement>(".progress-fill")!;
export function setProgress(percent: number): void {
const clamped = Math.min(100, Math.max(0, percent));
bar.setAttribute("aria-valuenow", String(Math.round(clamped)));
animate(fill, { scaleX: clamped / 100 }, { type: "spring", stiffness: 120, damping: 20 });
}
setProgress(35);
setTimeout(() => setProgress(80), 400);Number counter
Animate a plain number and format it in onUpdate.
<p>Downloads <strong class="downloads">0</strong></p>import { animate } from "@animaly/dom";
const output = document.querySelector<HTMLElement>(".downloads")!;
const format = new Intl.NumberFormat("en-US");
animate(0, 128450, {
duration: 1.6,
ease: "easeOut",
onUpdate: (latest) => {
output.textContent = format.format(Math.round(latest));
},
});Price ticker
A motion value holds the price. Each new quote retargets the same spring, so fast updates stay smooth.
<p class="price" style="font-variant-numeric:tabular-nums">$0.00</p>import { animate, motionValue } from "@animaly/dom";
const output = document.querySelector<HTMLElement>(".price")!;
const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const price = motionValue(0);
price.on("change", (latest) => {
output.textContent = currency.format(latest);
});
// Every new quote retargets the same spring, which keeps its current velocity.
export function quote(next: number): void {
animate(price, next, { type: "spring", stiffness: 90, damping: 18 });
}
quote(182.4);
setTimeout(() => quote(179.95), 300);