Original by Plinth · MIT
Navigation
Dock Nav
A glass dock whose tiles swell as your pointer passes, with the nearest one lifting its label.
Install
npx shadcn@latest add https://plinthui.com/r/dock-nav.json'use client';
import { useRef } from 'react';
// The magnification is a transform driven from a custom property, and the
// label rides the tile's own scale — neither is expressible in Tailwind, so
// the material lives in the style element below.
const css = `
.dock {
display: flex;
align-items: flex-end;
gap: 10px;
padding: 10px 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
-webkit-backdrop-filter: blur(16px) saturate(160%);
backdrop-filter: blur(16px) saturate(160%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 18px 40px -18px rgba(0, 0, 0, 0.8);
}
.dock__item {
/* --s is written from the pointer's distance to this tile. Keeping it in a
custom property means the scale and the lift stay one transform and never
fight each other. */
--s: 1;
position: relative;
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: none;
border-radius: 13px;
color: rgba(255, 255, 255, 0.86);
background: rgba(255, 255, 255, 0.09);
transform: scale(var(--s)) translateY(calc((var(--s) - 1) * -9px));
transform-origin: bottom center;
/* Short, because the pointer is already driving the value continuously — a
long transition here would feel like lag, not weight. */
transition: transform 140ms ease-out, background-color 180ms ease;
}
.dock__item:hover,
.dock__item:focus-visible { background: rgba(255, 255, 255, 0.2); }
.dock__item svg {
width: 21px;
height: 21px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* The label rides the tile's own scale, so it lifts with the magnification
instead of drifting away from it. */
.dock__item::after {
content: attr(data-label);
position: absolute;
bottom: calc(100% + 9px);
padding: 3px 8px;
border-radius: 7px;
font: 500 11px/1.4 ui-sans-serif, system-ui, sans-serif;
white-space: nowrap;
color: #14121a;
background: rgba(255, 255, 255, 0.94);
opacity: 0;
transform: translateY(4px);
transition: opacity 140ms ease, transform 140ms ease;
pointer-events: none;
}
.dock__item:hover::after,
.dock__item:focus-visible::after { opacity: 1; transform: translateY(0); }
.dock__sr {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.dock__item { transition: background-color 180ms ease; }
}
`;
/** How far the pointer's influence reaches, and how much the nearest tile grows. */
const RANGE = 110;
const MAX = 0.55;
const ITEMS = [
{ label: 'Home', path: <><path d="M3 10.5 12 3l9 7.5" /><path d="M5.5 9.5V20h13V9.5" /></> },
{ label: 'Search', path: <><circle cx="11" cy="11" r="6.5" /><path d="m16 16 4.5 4.5" /></> },
{
label: 'Library',
path: <><path d="M4 5h6v14H4z" /><path d="M14 5h3v14h-3z" /><path d="m19 5.5 2 13" /></>,
},
{
label: 'Saved',
path: <path d="M12 4.5 14.2 9l5 .7-3.6 3.5.9 5-4.5-2.4L7.5 18l.9-5L4.8 9.7l5-.7z" />,
},
{
label: 'Profile',
path: <><circle cx="12" cy="8.5" r="3.8" /><path d="M5 20a7 7 0 0 1 14 0" /></>,
},
];
export function DockNav() {
const dockRef = useRef<HTMLElement>(null);
function magnify(event: React.PointerEvent<HTMLElement>) {
const dock = dockRef.current;
if (!dock) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
for (const item of Array.from(dock.querySelectorAll<HTMLElement>('.dock__item'))) {
const r = item.getBoundingClientRect();
const d = Math.abs(event.clientX - (r.left + r.width / 2));
// Smoothstep rather than a linear falloff, so neighbours ease into the
// bulge instead of forming a visible tent.
const t = Math.max(0, 1 - d / RANGE);
item.style.setProperty('--s', String(1 + MAX * (t * t * (3 - 2 * t))));
}
}
function reset() {
const dock = dockRef.current;
if (!dock) return;
for (const item of Array.from(dock.querySelectorAll<HTMLElement>('.dock__item'))) {
item.style.setProperty('--s', '1');
}
}
return (
<>
<style>{css}</style>
<nav
ref={dockRef}
aria-label="Application"
className="dock"
onPointerMove={magnify}
onPointerLeave={reset}
>
{ITEMS.map(item => (
<a key={item.label} className="dock__item" href="#" data-label={item.label}>
<svg viewBox="0 0 24 24" aria-hidden="true">
{item.path}
</svg>
<span className="dock__sr">{item.label}</span>
</a>
))}
</nav>
</>
);
}