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
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.
<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>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);
});Modal with backdrop
Fade the backdrop and spring the dialog in. Closing waits for the exit animation before hiding the backdrop and returning focus.
<button class="open-modal">Open dialog</button>
<div class="backdrop" hidden style="position:fixed;inset:0;background:rgba(0,0,0,0.4)">
<div class="dialog" role="dialog" aria-modal="true" aria-label="Confirm" style="width:320px;margin:20vh auto 0;padding:24px;border-radius:20px;background:#fff">
<p>Delete this project?</p>
<button class="close-modal">Cancel</button>
</div>
</div>import { animate } from "@animaly/dom";
const backdrop = document.querySelector<HTMLElement>(".backdrop")!;
const dialog = document.querySelector<HTMLElement>(".dialog")!;
const opener = document.querySelector<HTMLButtonElement>(".open-modal")!;
const closer = document.querySelector<HTMLButtonElement>(".close-modal")!;
async function open(): Promise<void> {
backdrop.hidden = false;
animate(backdrop, { opacity: [0, 1] }, { duration: 0.2 });
await animate(dialog, { opacity: [0, 1], scale: [0.94, 1], y: [12, 0] }, { type: "spring", stiffness: 380, damping: 30 });
closer.focus();
}
async function close(): Promise<void> {
animate(dialog, { opacity: 0, scale: 0.96 }, { duration: 0.15 });
await animate(backdrop, { opacity: 0 }, { duration: 0.2 });
backdrop.hidden = true;
opener.focus();
}
opener.addEventListener("click", open);
closer.addEventListener("click", close);
backdrop.addEventListener("click", (event) => {
if (event.target !== backdrop) return;
close();
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || backdrop.hidden) return;
close();
});Toast stack
Each toast springs in, slides out after its lifetime and collapses its height so the stack closes the gap.
<ol class="toasts" style="position:fixed;right:16px;bottom:16px;display:grid;gap:8px;margin:0;padding:0;list-style:none"></ol>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");Dropdown menu
Scale the menu from its corner and stagger the items a little behind it.
<div style="position:relative">
<button class="menu-button" aria-expanded="false" aria-controls="menu">Options</button>
<ul id="menu" class="menu" hidden style="position:absolute;top:44px;left:0;margin:0;padding:8px;list-style:none;border-radius:14px;background:#fff;box-shadow:0 12px 32px rgba(0,0,0,0.16);transform-origin:top left">
<li><button>Rename</button></li>
<li><button>Duplicate</button></li>
<li><button>Delete</button></li>
</ul>
</div>import { animate, stagger } from "@animaly/dom";
const button = document.querySelector<HTMLButtonElement>(".menu-button")!;
const menu = document.querySelector<HTMLUListElement>(".menu")!;
async function setOpen(open: boolean): Promise<void> {
button.setAttribute("aria-expanded", String(open));
if (open) {
menu.hidden = false;
animate(menu, { opacity: [0, 1], scale: [0.95, 1], y: [-6, 0] }, { type: "spring", stiffness: 500, damping: 32 });
animate(menu.querySelectorAll("li"), { opacity: [0, 1], x: [-6, 0] }, { duration: 0.2, delay: stagger(0.03, { startDelay: 0.04 }) });
return;
}
await animate(menu, { opacity: 0, scale: 0.97 }, { duration: 0.12 });
menu.hidden = true;
}
button.addEventListener("click", () => setOpen(button.getAttribute("aria-expanded") !== "true"));
button.click();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.
<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>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.
<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>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.
<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>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.
<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>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();