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.
---
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>