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.

interactions/magnetic-cta.astro
---
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>