Micro-interaction Gallery
A mini gallery of tactile micro-interactions: a magnetic button that pulls toward the cursor and springs back, a material-style ripple that expands from the click point, and an elastic toggle switch. Pointer-fine and reduced-motion aware.
- pointer offset → translate
- spring easing
- ripple from event point
- prefers-reduced-motion
Source
A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.
---
export const meta = {
title: "Micro-interaction Gallery",
tags: ["interaction", "pointer", "magnetic", "ripple"],
description:
"A mini gallery of tactile micro-interactions: a magnetic button that pulls toward the cursor and springs back, a material-style ripple that expands from the click point, and an elastic toggle switch. Pointer-fine and reduced-motion aware.",
tech: ["pointer offset → translate", "spring easing", "ripple from event point", "prefers-reduced-motion"],
interactive: true,
height: 420,
};
---
<section class="mi" data-mi>
<div class="mi__grid">
<figure class="demo">
<div class="demo__stage">
<button class="magnet" data-magnet type="button">
<span class="magnet__label" data-magnet-label>Pull me</span>
</button>
</div>
<figcaption class="demo__cap"><b>Magnetic</b> · follows the cursor, springs back</figcaption>
</figure>
<figure class="demo">
<div class="demo__stage">
<button class="ripple" data-ripple type="button">Click for ripple</button>
</div>
<figcaption class="demo__cap"><b>Ripple</b> · expands from the exact click point</figcaption>
</figure>
<figure class="demo">
<div class="demo__stage">
<button class="eswitch" data-eswitch type="button" role="switch" aria-checked="false"
aria-label="Elastic toggle">
<span class="eswitch__thumb"></span>
</button>
</div>
<figcaption class="demo__cap"><b>Elastic switch</b> · overshoots, then settles</figcaption>
</figure>
</div>
</section>
<style>
.mi {
min-height: 420px; display: grid; place-items: center;
padding: 2.5rem 1.5rem; background: var(--bg, #0e0f1a);
color: var(--ink, #f2f2f7); font-family: var(--font-sans, system-ui, sans-serif);
}
.mi__grid {
display: grid; gap: 1.25rem; width: min(60rem, 100%);
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
}
.demo {
margin: 0; border-radius: var(--radius-l, 22px);
background: var(--surface, #16171f); border: 1px solid var(--border, #2c2d38);
display: grid; overflow: hidden;
}
.demo__stage {
display: grid; place-items: center; padding: 2.5rem 1.25rem; min-height: 9.5rem;
background:
radial-gradient(120% 80% at 50% 0%, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 14%, transparent), transparent 60%),
var(--bg-sunk, #0b0c15);
}
.demo__cap { padding: 0.85rem 1rem; font-size: 0.82rem; color: var(--ink-faint, #8a8c9c); border-top: 1px solid var(--border, #2c2d38); }
.demo__cap b { color: var(--ink, #fff); font-weight: 700; }
/* ---- magnetic button --------------------------------------------------- */
.magnet {
--tx: 0px; --ty: 0px; --lx: 0px; --ly: 0px;
position: relative; border: 0; cursor: pointer; font: inherit; font-weight: 700;
color: var(--bg, #0e0f1a); padding: 0.95rem 1.9rem; border-radius: 999px;
background: linear-gradient(100deg, var(--spectrum-3, #45d3e8), var(--spectrum-1, #8b5cf6));
box-shadow: 0 8px 24px -6px var(--spectrum-1, #8b5cf6);
transform: translate(var(--tx), var(--ty));
transition: transform var(--dur, 320ms) var(--ease-spring, cubic-bezier(0.34,1.56,0.64,1));
}
.magnet[data-active] { transition: transform 120ms ease-out; }
.magnet__label { display: inline-block; transform: translate(var(--lx), var(--ly)); transition: inherit; }
/* ---- ripple button ----------------------------------------------------- */
.ripple {
position: relative; overflow: hidden; isolation: isolate;
border: 1px solid var(--border-strong, #45465a); cursor: pointer; font: inherit; font-weight: 600;
color: var(--ink, #fff); padding: 0.95rem 1.6rem; border-radius: var(--radius-m, 14px);
background: var(--surface-2, #20212b);
transition: transform var(--dur-fast, 160ms) var(--ease-spring, ease);
}
.ripple:active { transform: scale(0.97); }
.ripple :global(.ink) {
position: absolute; border-radius: 50%; pointer-events: none; z-index: -1;
background: radial-gradient(circle, color-mix(in oklab, var(--spectrum-3, #45d3e8) 70%, transparent), transparent 70%);
transform: translate(-50%, -50%) scale(0); opacity: 0.7;
animation: mi-ripple 620ms var(--ease-out, ease-out) forwards;
}
@keyframes mi-ripple {
to { transform: translate(-50%, -50%) scale(1); opacity: 0; }
}
/* ---- elastic switch ---------------------------------------------------- */
.eswitch {
position: relative; width: 74px; height: 40px; border-radius: 999px; cursor: pointer; padding: 0;
border: 1px solid var(--border, #2c2d38); background: var(--surface-2, #24252f);
transition: background var(--dur, 320ms) var(--ease-out, ease);
}
.eswitch__thumb {
position: absolute; top: 4px; left: 4px; width: 30px; height: 30px; border-radius: 50%;
background: linear-gradient(160deg, #fff, #d7dbe8); box-shadow: 0 3px 8px rgb(0 0 0 / 0.4);
transform: translateX(0);
transition: transform var(--dur-slow, 640ms) var(--ease-spring, cubic-bezier(0.34,1.7,0.5,1));
}
.eswitch[aria-checked="true"] {
background: linear-gradient(100deg, var(--spectrum-4, #34d399), var(--spectrum-3, #45d3e8));
border-color: transparent;
}
.eswitch[aria-checked="true"] .eswitch__thumb { transform: translateX(34px); }
@media (prefers-reduced-motion: reduce) {
.magnet, .magnet__label, .eswitch__thumb, .ripple { transition-duration: 1ms; }
.ripple :global(.ink) { animation: none; display: none; }
}
</style>
<script>
(function () {
function bind() {
document.querySelectorAll<HTMLElement>("[data-mi]").forEach((root) => {
if (root.dataset.bound) return;
root.dataset.bound = "1";
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const fine = matchMedia("(pointer: fine)").matches;
/* --- magnetic button --- */
const magnet = root.querySelector<HTMLElement>("[data-magnet]");
const label = root.querySelector<HTMLElement>("[data-magnet-label]");
if (magnet && fine && !reduce) {
const pull = 0.4, labelPull = 0.22, radius = 90;
magnet.addEventListener("pointermove", (e) => {
const r = magnet.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
magnet.dataset.active = "1";
magnet.style.setProperty("--tx", `${Math.max(-radius, Math.min(radius, dx)) * pull}px`);
magnet.style.setProperty("--ty", `${Math.max(-radius, Math.min(radius, dy)) * pull}px`);
if (label) {
label.style.setProperty("--lx", `${dx * labelPull}px`);
label.style.setProperty("--ly", `${dy * labelPull}px`);
}
});
magnet.addEventListener("pointerleave", () => {
delete magnet.dataset.active;
magnet.style.setProperty("--tx", "0px");
magnet.style.setProperty("--ty", "0px");
label?.style.setProperty("--lx", "0px");
label?.style.setProperty("--ly", "0px");
});
}
/* --- ripple button --- */
const ripple = root.querySelector<HTMLElement>("[data-ripple]");
ripple?.addEventListener("pointerdown", (e) => {
if (reduce) return;
const r = ripple.getBoundingClientRect();
const size = Math.max(r.width, r.height) * 2.2;
const ink = document.createElement("span");
ink.className = "ink";
ink.style.width = ink.style.height = `${size}px`;
ink.style.left = `${e.clientX - r.left}px`;
ink.style.top = `${e.clientY - r.top}px`;
ripple.appendChild(ink);
ink.addEventListener("animationend", () => ink.remove());
});
/* --- elastic switch --- */
const sw = root.querySelector<HTMLElement>("[data-eswitch]");
sw?.addEventListener("click", () => {
const on = sw.getAttribute("aria-checked") === "true";
sw.setAttribute("aria-checked", on ? "false" : "true");
});
});
}
document.addEventListener("astro:page-load", bind);
if (document.readyState !== "loading") bind();
})();
</script>