animaly
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.

index.html
<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>
list-enter.ts
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.

index.html
<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>
list-remove.ts
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.

index.html
<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>
badge.ts
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.

index.html
<button class="like" aria-pressed="false" aria-label="Like" style="position:relative;font-size:28px;background:none;border:0">♥</button>
like-burst.ts
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.

index.html
<form class="signup">
  <input class="email" type="email" required placeholder="you@example.com">
  <button>Sign up</button>
</form>
shake.ts
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.

index.html
<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>
checkmark.ts
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.

index.html
<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>
loading-dots.ts
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.

index.html
<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>
skeleton.ts
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.

index.html
<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>
progress.ts
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.

index.html
<p>Downloads <strong class="downloads">0</strong></p>
counter.ts
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.

index.html
<p class="price" style="font-variant-numeric:tabular-nums">$0.00</p>
price-ticker.ts
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);