animaly
Documentation menu

Recipes

Component recipes

Buttons, cards, modals, toasts, menus, tabs and tooltips. Each recipe has the markup and the code it needs; paste both and it runs. Every example is typechecked and run against the published @animaly/dom.

On this page

Button press

A stiff spring makes a button feel physical. Pressing scales it down; releasing or leaving springs it back.

index.html
<button class="press" style="padding:12px 20px;border:0;border-radius:999px;background:#000;color:#fff">Save changes</button>
press.ts
import { animate } from "@animaly/dom";

const button = document.querySelector<HTMLButtonElement>(".press")!;
const spring = { type: "spring", stiffness: 500, damping: 30 } as const;

button.addEventListener("pointerdown", () => animate(button, { scale: 0.95 }, spring));
button.addEventListener("pointerup", () => animate(button, { scale: 1 }, spring));
button.addEventListener("pointerleave", () => animate(button, { scale: 1 }, spring));

Card hover lift

Lift a card and deepen its shadow on hover. boxShadow is animated number by number, so both shadows need the same layers.

index.html
<article class="card" style="width:260px;padding:20px;border-radius:16px;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,0.08)">
  <h3>Spring physics</h3>
  <p>Hover me.</p>
</article>
card-hover.ts
import { animate } from "@animaly/dom";

const card = document.querySelector<HTMLElement>(".card")!;
const spring = { type: "spring", stiffness: 300, damping: 24 } as const;

card.addEventListener("pointerenter", () => {
  animate(card, { y: -6, boxShadow: "0 16px 32px rgba(0, 0, 0, 0.16)" }, spring);
});

card.addEventListener("pointerleave", () => {
  animate(card, { y: 0, boxShadow: "0 1px 2px rgba(0, 0, 0, 0.08)" }, spring);
});

Toast stack

Each toast springs in, slides out after its lifetime and collapses its height so the stack closes the gap.

index.html
<ol class="toasts" style="position:fixed;right:16px;bottom:16px;display:grid;gap:8px;margin:0;padding:0;list-style:none"></ol>
toast.ts
import { animate } from "@animaly/dom";

const stack = document.querySelector<HTMLOListElement>(".toasts")!;

export function toast(message: string, lifetime = 3000): void {
  const item = document.createElement("li");
  item.textContent = message;
  item.setAttribute("role", "status");
  item.style.cssText = "padding:12px 16px;border-radius:12px;background:#111;color:#fff;overflow:hidden";
  stack.append(item);

  animate(item, { y: [24, 0], opacity: [0, 1], scale: [0.96, 1] }, { type: "spring", stiffness: 400, damping: 30 });

  window.setTimeout(async () => {
    await animate(item, { x: 40, opacity: 0 }, { duration: 0.2, ease: "easeIn" });
    await animate(item, { height: 0, paddingTop: 0, paddingBottom: 0 }, { duration: 0.15 });
    item.remove();
  }, lifetime);
}

toast("Changes saved");

Tabs with a sliding indicator

One indicator springs to the active tab. Because it is 1px wide, scaleX doubles as its width in pixels, and as a transform it never triggers layout.

index.html
<div class="tabs" role="tablist" style="position:relative;display:flex;gap:4px">
  <button role="tab" aria-selected="true">Overview</button>
  <button role="tab" aria-selected="false">Activity</button>
  <button role="tab" aria-selected="false">Settings</button>
  <span class="tab-indicator" style="position:absolute;left:0;bottom:-4px;height:2px;width:1px;background:#000;transform-origin:left"></span>
</div>
tabs.ts
import { animate } from "@animaly/dom";

const tabs = Array.from(document.querySelectorAll<HTMLButtonElement>("[role=tab]"));
const indicator = document.querySelector<HTMLElement>(".tab-indicator")!;

// The indicator is 1px wide, so scaleX in pixels is its width.
function select(tab: HTMLButtonElement): void {
  tabs.forEach((other) => other.setAttribute("aria-selected", String(other === tab)));
  animate(indicator, { x: tab.offsetLeft, scaleX: tab.offsetWidth }, { type: "spring", stiffness: 400, damping: 34 });
}

tabs.forEach((tab) => tab.addEventListener("click", () => select(tab)));
select(tabs[0]);

Accordion with auto height

Animate height to "auto"; animaly measures the target height for you.

index.html
<section class="accordion">
  <button class="accordion-toggle" aria-expanded="false" aria-controls="answer">How fast is it?</button>
  <div id="answer" class="accordion-panel" style="height:0;overflow:hidden">
    <p style="margin:0;padding:12px 0">One style write per element per frame.</p>
  </div>
</section>
accordion.ts
import { animate } from "@animaly/dom";

const toggle = document.querySelector<HTMLButtonElement>(".accordion-toggle")!;
const panel = document.querySelector<HTMLElement>(".accordion-panel")!;

toggle.addEventListener("click", () => {
  const open = toggle.getAttribute("aria-expanded") !== "true";
  toggle.setAttribute("aria-expanded", String(open));
  animate(panel, { height: open ? "auto" : 0, opacity: open ? 1 : 0 }, { type: "spring", stiffness: 300, damping: 32 });
});

toggle.click();

Tooltip

Show after a short hover delay, on focus as well as pointer, and hide immediately.

index.html
<span style="position:relative;display:inline-block;margin-top:48px">
  <button class="has-tooltip" aria-describedby="tip">Shortcut</button>
  <span id="tip" role="tooltip" class="tooltip" style="position:absolute;bottom:calc(100% + 8px);left:0;padding:6px 10px;border-radius:8px;background:#000;color:#fff;font-size:12px;white-space:nowrap;opacity:0;pointer-events:none">Press K</span>
</span>
tooltip.ts
import { animate } from "@animaly/dom";

const trigger = document.querySelector<HTMLButtonElement>(".has-tooltip")!;
const tip = document.querySelector<HTMLElement>(".tooltip")!;
let timer = 0;

const show = (): void => {
  window.clearTimeout(timer);
  timer = window.setTimeout(() => {
    animate(tip, { opacity: 1, y: [4, 0], scale: [0.96, 1] }, { type: "spring", stiffness: 500, damping: 30 });
  }, 300);
};

const hide = (): void => {
  window.clearTimeout(timer);
  animate(tip, { opacity: 0 }, { duration: 0.1 });
};

trigger.addEventListener("pointerenter", show);
trigger.addEventListener("focus", show);
trigger.addEventListener("pointerleave", hide);
trigger.addEventListener("blur", hide);
trigger.focus();

Image zoom on click

Zoom toward the point that was clicked by moving transformOrigin before scaling.

index.html
<figure class="zoom" style="overflow:hidden;width:320px;margin:0;border-radius:16px;cursor:zoom-in">
  <img class="zoom-image" alt="" width="320" height="200" style="display:block;background:linear-gradient(135deg,#7c3aed,#ec4899)" src="data:image/gif;base64,R0lGODlhAQABAAAAACw=">
</figure>
image-zoom.ts
import { animate } from "@animaly/dom";

const frame = document.querySelector<HTMLElement>(".zoom")!;
const image = document.querySelector<HTMLImageElement>(".zoom-image")!;
let zoomed = false;

frame.addEventListener("click", (event) => {
  const rect = frame.getBoundingClientRect();
  image.style.transformOrigin = event.clientX - rect.left + "px " + (event.clientY - rect.top) + "px";
  zoomed = !zoomed;
  frame.style.cursor = zoomed ? "zoom-out" : "zoom-in";
  animate(image, { scale: zoomed ? 2.2 : 1 }, { type: "spring", stiffness: 260, damping: 28 });
});

frame.click();

Copy button confirmation

Swap the label for a confirmation and back again, using two stacked labels in one grid cell.

index.html
<button class="copy" style="display:inline-grid">
  <span class="copy-label" style="grid-area:1/1">Copy</span>
  <span class="copy-done" style="grid-area:1/1;opacity:0">Copied</span>
</button>
copy-button.ts
import { animate } from "@animaly/dom";

const button = document.querySelector<HTMLButtonElement>(".copy")!;
const label = document.querySelector<HTMLElement>(".copy-label")!;
const done = document.querySelector<HTMLElement>(".copy-done")!;

button.addEventListener("click", async () => {
  await navigator.clipboard.writeText("pnpm add @animaly/dom").catch(() => undefined);
  animate(label, { opacity: 0, y: -8 }, { duration: 0.15 });
  await animate(done, { opacity: [0, 1], y: [8, 0] }, { type: "spring", stiffness: 500, damping: 30 });
  setTimeout(() => {
    animate(done, { opacity: 0, y: -8 }, { duration: 0.15 });
    animate(label, { opacity: [0, 1], y: [8, 0] }, { duration: 0.2 });
  }, 1400);
});

button.click();

Magnetic button

Springs pull the button part of the way toward the pointer and release it when the pointer leaves.

index.html
<button class="magnetic" style="padding:16px 28px;border:0;border-radius:999px;background:#000;color:#fff">Get started</button>
magnetic-button.ts
import { animate } from "@animaly/dom";

const button = document.querySelector<HTMLButtonElement>(".magnetic")!;
const pull = 0.35;
const spring = { type: "spring", stiffness: 220, damping: 16 } as const;

button.addEventListener("pointermove", (event) => {
  const rect = button.getBoundingClientRect();
  const dx = event.clientX - (rect.left + rect.width / 2);
  const dy = event.clientY - (rect.top + rect.height / 2);
  animate(button, { x: dx * pull, y: dy * pull }, spring);
});

button.addEventListener("pointerleave", () => animate(button, { x: 0, y: 0 }, spring));