Magnify Dock

A macOS-style dock whose tiles magnify based on cursor distance — the hovered icon swells, neighbours ease up with a falloff curve computed in JS. Tooltips on hover, and it degrades to a plain, tappable dock on touch or reduced-motion.

  • pointer distance falloff
  • requestAnimationFrame
  • CSS custom props

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.

navigation/magnify-dock.astro
---
export const meta = {
  title: "Magnify Dock",
  tags: ["navigation", "dock", "pointer", "macos"],
  description:
    "A macOS-style dock whose tiles magnify based on cursor distance — the hovered icon swells, neighbours ease up with a falloff curve computed in JS. Tooltips on hover, and it degrades to a plain, tappable dock on touch or reduced-motion.",
  tech: ["pointer distance falloff", "requestAnimationFrame", "CSS custom props"],
  interactive: true,
  height: 300,
};

const apps = [
  { glyph: "◐", label: "Finder", g: "var(--spectrum-3, #45d3e8)" },
  { glyph: "✦", label: "Launchpad", g: "var(--spectrum-1, #8b5cf6)" },
  { glyph: "✉", label: "Mail", g: "var(--spectrum-2, #5b9df6)" },
  { glyph: "◇", label: "Notes", g: "var(--spectrum-5, #fbbf24)" },
  { glyph: "♪", label: "Music", g: "var(--spectrum-6, #f472b6)" },
  { glyph: "◎", label: "Photos", g: "var(--spectrum-4, #34d399)" },
  { glyph: "⚙", label: "Settings", g: "var(--ink-muted, #b7b9c9)" },
];
---

<section class="dock-stage">
  <nav class="dock" aria-label="Application dock" data-dock>
    <ul class="dock__list">
      {apps.map((a) => (
        <li class="dock__cell" data-dock-cell style={`--tile: ${a.g};`}>
          <button type="button" class="dock__tile" aria-label={a.label}>
            <span class="dock__glyph" aria-hidden="true">{a.glyph}</span>
          </button>
          <span class="dock__tooltip" role="tooltip">{a.label}</span>
        </li>
      ))}
    </ul>
  </nav>
</section>

<style>
  .dock-stage {
    min-height: 300px;
    display: grid;
    place-items: end center;
    padding: 2rem 1rem 2.5rem;
    background:
      radial-gradient(50rem 20rem at 50% 120%, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 22%, transparent), transparent 70%),
      var(--bg-sunk, #0b0c15);
    font-family: var(--font-sans, system-ui, sans-serif);
  }

  .dock {
    --size: 3.4rem; /* base tile size */
    display: flex;
    align-items: flex-end;
    padding: 0.55rem 0.7rem;
    border-radius: var(--radius-l, 22px);
    border: 1px solid color-mix(in oklab, #fff 12%, transparent);
    background: color-mix(in oklab, var(--surface, #16171f) 55%, transparent);
    backdrop-filter: blur(16px) saturate(1.4);
    -webkit-backdrop-filter: blur(16px) saturate(1.4);
    box-shadow: var(--shadow-l, 0 20px 50px rgba(0, 0, 0, 0.5)),
      inset 0 1px 0 color-mix(in oklab, #fff 18%, transparent);
  }

  .dock__list {
    display: flex;
    align-items: flex-end;
    gap: 0.35rem;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .dock__cell {
    --scale: 1;
    position: relative;
    display: flex;
    justify-content: center;
    /* width tracks the scaled tile so neighbours are pushed aside */
    width: calc(var(--size) * var(--scale));
    transition: width 0.06s linear;
  }

  .dock__tile {
    width: var(--size);
    height: var(--size);
    padding: 0;
    border: none;
    cursor: pointer;
    border-radius: 27%;
    transform-origin: bottom center;
    transform: scale(var(--scale)) translateY(calc((var(--scale) - 1) * -0.35rem));
    transition: transform 0.06s linear;
    background:
      radial-gradient(120% 120% at 30% 20%, color-mix(in oklab, #fff 40%, transparent), transparent 55%),
      linear-gradient(160deg, color-mix(in oklab, var(--tile) 85%, #fff 15%), color-mix(in oklab, var(--tile) 80%, #000 20%));
    box-shadow: inset 0 1px 0 color-mix(in oklab, #fff 45%, transparent),
      0 4px 10px color-mix(in oklab, #000 40%, transparent);
    display: grid;
    place-items: center;
    will-change: transform;
  }
  .dock__glyph {
    font-size: 1.5rem;
    line-height: 1;
    color: color-mix(in oklab, #10121c 82%, var(--tile) 18%);
    text-shadow: 0 1px 0 color-mix(in oklab, #fff 30%, transparent);
  }

  /* reflection dot under active tiles */
  .dock__cell::after {
    content: "";
    position: absolute;
    bottom: -0.65rem;
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: color-mix(in oklab, #fff 55%, transparent);
    opacity: calc(var(--scale) - 1);
    transition: opacity 0.1s linear;
  }

  .dock__tooltip {
    position: absolute;
    bottom: calc(var(--size) * var(--scale) + 0.9rem);
    left: 50%;
    transform: translateX(-50%) translateY(4px);
    padding: 0.3rem 0.6rem;
    border-radius: 8px;
    background: var(--surface, #16171f);
    border: 1px solid var(--border-strong, #3a3d4d);
    color: var(--ink, #fff);
    font-size: 0.78rem;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    box-shadow: var(--shadow-m, 0 8px 20px rgba(0, 0, 0, 0.4));
    transition: opacity 0.15s var(--ease-out, ease), transform 0.15s var(--ease-out, ease);
  }
  .dock__tile:hover + .dock__tooltip,
  .dock__tile:focus-visible + .dock__tooltip {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
  }

  @media (prefers-reduced-motion: reduce) {
    .dock__cell,
    .dock__tile { transition: none; }
  }
</style>

<script>
  (function () {
    function bind() {
      document.querySelectorAll<HTMLElement>("[data-dock]").forEach((dock) => {
        if (dock.dataset.bound) return;
        dock.dataset.bound = "1";

        const cells = Array.from(dock.querySelectorAll<HTMLElement>("[data-dock-cell]"));
        const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
        const fine = matchMedia("(pointer: fine)").matches;

        // On touch / reduced-motion: stay flat and tappable, skip magnify.
        if (reduce || !fine) return;

        const MAX = 1.75;   // peak scale for the hovered tile
        const RANGE = 110;   // px falloff radius
        let pointerX: number | null = null;
        let raf = 0;

        function apply() {
          raf = 0;
          cells.forEach((cell) => {
            let scale = 1;
            if (pointerX !== null) {
              const r = cell.getBoundingClientRect();
              const cx = r.left + r.width / 2;
              const d = Math.abs(pointerX - cx);
              if (d < RANGE) {
                // cosine falloff — smooth swell, gentle neighbours
                const t = d / RANGE;
                scale = 1 + (MAX - 1) * (Math.cos(t * Math.PI) * 0.5 + 0.5);
              }
            }
            cell.style.setProperty("--scale", scale.toFixed(3));
          });
        }
        function schedule() {
          if (!raf) raf = requestAnimationFrame(apply);
        }

        dock.addEventListener("pointermove", (e) => {
          if (e.pointerType === "touch") return;
          pointerX = e.clientX;
          schedule();
        });
        dock.addEventListener("pointerleave", () => {
          pointerX = null;
          schedule();
        });
        // keyboard focus should also swell the focused tile
        dock.addEventListener("focusin", (e) => {
          const cell = (e.target as HTMLElement).closest<HTMLElement>("[data-dock-cell]");
          if (cell) {
            const r = cell.getBoundingClientRect();
            pointerX = r.left + r.width / 2;
            schedule();
          }
        });
        dock.addEventListener("focusout", () => {
          if (!dock.matches(":hover")) { pointerX = null; schedule(); }
        });
      });
    }
    document.addEventListener("astro:page-load", bind);
    if (document.readyState !== "loading") bind();
  })();
</script>