Skip to content
Plinth

Original by Plinth · MIT

Navigation

Dock Nav

A glass dock whose tiles swell as your pointer passes, with the nearest one lifting its label.

#navigation#dock#magnify#pointer#backdrop-filter

Install

npx shadcn@latest add https://plinthui.com/r/dock-nav.json
Open in v0
'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>
    </>
  );
}

More in Navigation

Similar tags