№ 001
Pearl Nav
A pearl bead that rides along the bar on a spring, lifting the surface beneath it. Drag it.
- Version
- 1.0.0
- Download
- 7 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, spring, drag
Usage
Copy pearl-nav.css and pearl-nav.js (and assets/, if there is one) into your project, then:
new PearlNav(element, { tabs: [{ label, icon, color }], active: 0, onChange(tab, index) {} })
`icon` is a key of PearlNav.icons or a raw SVG string.Source
/* Pearl nav — a tab bar whose active tab rises out of the surface as a "bead". */
.pn {
--accent: #d7f23a;
position: relative;
width: 100%;
max-width: 360px;
height: 64px;
margin-top: 34px; /* room for the bead + hill above the bar */
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.pn-shape {
position: absolute;
left: 0;
overflow: visible;
pointer-events: none;
filter: drop-shadow(0 16px 26px rgba(30, 22, 48, 0.3));
}
.pn-shape path {
stroke: rgba(255, 255, 255, 0.09);
stroke-width: 1;
}
.pn-tabs {
position: absolute;
inset: 0;
}
.pn-tab {
position: absolute;
top: 0;
height: 100%;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 14px;
background: none;
color: #8f889e;
font: inherit;
cursor: pointer;
transition: color 0.2s;
}
.pn-tab:hover { color: #ebe7f2; }
.pn-tab:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -8px;
}
.pn-icon {
display: grid;
transition: opacity 0.2s, transform 0.35s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.pn-icon svg { width: 20px; height: 20px; }
.pn-tab[aria-selected="true"] .pn-icon {
opacity: 0;
transform: translateY(-12px) scale(0.6);
}
.pn-label {
position: absolute;
bottom: 9px;
left: 50%;
transform: translate(-50%, 4px);
color: var(--accent);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
opacity: 0;
transition: opacity 0.25s, transform 0.3s, color 0.5s;
}
.pn-tab[aria-selected="true"] .pn-label {
opacity: 1;
transform: translate(-50%, 0);
}
.pn-bead {
position: absolute;
top: 0;
left: 0;
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 50%;
color: var(--accent);
background-color: currentColor;
background-image:
radial-gradient(circle at 34% 28%, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0) 58%),
radial-gradient(circle at 60% 88%, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0) 60%);
box-shadow:
0 12px 30px -6px currentColor,
inset 0 -2px 4px rgba(0, 0, 0, 0.15);
cursor: grab;
touch-action: none;
will-change: transform;
transition: color 0.5s;
}
.pn-bead.is-dragging { cursor: grabbing; }
.pn-bead-icon {
display: grid;
color: #17140d;
}
.pn-bead-icon svg { width: 22px; height: 22px; }
.pn-bead-icon.pop { animation: pn-pop 0.4s cubic-bezier(0.3, 1.6, 0.5, 1); }
@keyframes pn-pop {
from { opacity: 0; transform: scale(0.4) rotate(-20deg); }
}
@media (prefers-reduced-motion: reduce) {
.pn-icon, .pn-label { transition: opacity 0.2s, color 0.5s; }
.pn-bead-icon.pop { animation: none; }
} /* Pearl nav — usage:
* new PearlNav(element, { tabs: [{ label, icon, color }], active: 0, onChange(tab, index) {} })
* `icon` is a key of PearlNav.icons or a raw SVG string.
*/
(function () {
const BEAD_R = 27; // bead radius
const BEAD_Y = -4; // bead centre relative to the bar's top edge
const BAR_H = 64;
const PAD = 30; // inner horizontal padding before the first/last tab
const CORNER = 18;
const HILL_H = 20; // how far the surface rises under the bead
const HILL_W = 44; // half-width of the rise
const STIFFNESS = 420;
const DAMPING = 32;
const svg = (body) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const ICONS = {
home: svg('<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20h14V9.5"/><path d="M10 20v-5h4v5"/>'),
user: svg('<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>'),
message: svg('<path d="M21 11.5a8.5 8.5 0 0 1-12.6 7.4L3 20l1.2-5A8.5 8.5 0 1 1 21 11.5Z"/>'),
camera: svg('<path d="M4 8h3l2-3h6l2 3h3a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1Z"/><circle cx="12" cy="13.5" r="3.5"/>'),
sliders: svg('<path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/>'),
};
let uid = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
class PearlNav {
constructor(root, { tabs, active = 0, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = active;
this.onChange = onChange;
this.x = 0;
this.v = 0;
this.target = 0;
this.dragging = false;
this.running = false;
this.build();
this.layout();
this.x = this.target = this.centers[active];
this.select(active, { snap: false, force: true });
this.render();
new ResizeObserver(() => this.layout()).observe(root);
}
icon(key) {
return ICONS[key] || key;
}
build() {
const gid = `pn-fill-${++uid}`;
this.root.classList.add('pn');
this.root.innerHTML = `
<svg class="pn-shape" aria-hidden="true">
<defs>
<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#211d2a"/>
<stop offset="1" stop-color="#121017"/>
</linearGradient>
</defs>
<path fill="url(#${gid})"/>
</svg>
<div class="pn-tabs" role="tablist" aria-label="Main"></div>
<div class="pn-bead" aria-hidden="true"><span class="pn-bead-icon"></span></div>`;
this.shape = this.root.querySelector('.pn-shape');
this.path = this.shape.querySelector('path');
this.bead = this.root.querySelector('.pn-bead');
this.beadIcon = this.root.querySelector('.pn-bead-icon');
const list = this.root.querySelector('.pn-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'pn-tab';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', tab.label);
b.innerHTML = `<span class="pn-icon">${this.icon(tab.icon)}</span><span class="pn-label">${tab.label}</span>`;
b.addEventListener('click', () => this.select(i));
list.appendChild(b);
return b;
});
list.addEventListener('keydown', (e) => {
const last = this.tabs.length - 1;
const next = { ArrowRight: this.active + 1, ArrowLeft: this.active - 1, Home: 0, End: last }[e.key];
if (next === undefined) return;
e.preventDefault();
const i = (next + this.tabs.length) % this.tabs.length;
this.select(i);
this.buttons[i].focus();
});
this.bead.addEventListener('pointerdown', (e) => this.dragStart(e));
this.bead.addEventListener('pointermove', (e) => this.dragMove(e));
this.bead.addEventListener('pointerup', (e) => this.dragEnd(e));
this.bead.addEventListener('pointercancel', (e) => this.dragEnd(e));
}
layout() {
this.w = this.root.clientWidth;
const slot = (this.w - PAD * 2) / this.tabs.length;
this.centers = this.tabs.map((_, i) => PAD + slot * (i + 0.5));
this.buttons.forEach((b, i) => {
b.style.left = `${this.centers[i] - slot / 2}px`;
b.style.width = `${slot}px`;
});
const top = HILL_H + 2;
this.shape.style.top = `${-top}px`;
this.shape.setAttribute('width', this.w);
this.shape.setAttribute('height', BAR_H + top);
this.shape.setAttribute('viewBox', `0 ${-top} ${this.w} ${BAR_H + top}`);
if (!this.dragging) {
this.target = this.centers[this.active];
if (!this.running) this.x = this.target;
}
this.render();
}
// Bar outline with a smooth rise centred on the bead.
shapePath(cx) {
const W = this.w, H = BAR_H, r = CORNER, h = HILL_H, w = HILL_W;
const a = Math.max(cx - w, r);
const b = Math.min(cx + w, W - r);
return [
`M${r},0 L${a},0`,
`C${cx - w / 2},0 ${cx - w / 2},${-h} ${cx},${-h}`,
`C${cx + w / 2},${-h} ${cx + w / 2},0 ${b},0`,
`L${W - r},0 A${r},${r} 0 0 1 ${W},${r}`,
`L${W},${H - r} A${r},${r} 0 0 1 ${W - r},${H}`,
`L${r},${H} A${r},${r} 0 0 1 0,${H - r}`,
`L0,${r} A${r},${r} 0 0 1 ${r},0 Z`,
].join(' ');
}
render() {
this.path.setAttribute('d', this.shapePath(this.x));
// Squash and stretch along the direction of travel.
const s = Math.min(Math.abs(this.v) / 2600, 0.22);
this.bead.style.transform =
`translate(${this.x - BEAD_R}px, ${BEAD_Y - BEAD_R}px) scale(${1 + s}, ${1 - s * 0.7})`;
}
select(i, { snap = true, force = false } = {}) {
if (i !== this.active || force) {
this.active = i;
const tab = this.tabs[i];
this.buttons.forEach((b, j) => {
b.setAttribute('aria-selected', String(j === i));
b.tabIndex = j === i ? 0 : -1;
});
this.root.style.setProperty('--accent', tab.color);
this.beadIcon.innerHTML = this.icon(tab.icon);
this.beadIcon.classList.remove('pop');
void this.beadIcon.offsetWidth; // restart the animation
this.beadIcon.classList.add('pop');
if (this.onChange) this.onChange(tab, i);
}
if (snap) {
this.target = this.centers[i];
this.animate();
}
}
nearest(x) {
let best = 0;
this.centers.forEach((c, i) => {
if (Math.abs(c - x) < Math.abs(this.centers[best] - x)) best = i;
});
return best;
}
dragStart(e) {
this.dragging = true;
this.grabOffset = e.clientX - this.root.getBoundingClientRect().left - this.x;
this.lastMove = { x: e.clientX, t: e.timeStamp };
this.bead.setPointerCapture(e.pointerId);
this.bead.classList.add('is-dragging');
this.animate();
}
dragMove(e) {
if (!this.dragging) return;
const left = this.root.getBoundingClientRect().left;
const min = this.centers[0];
const max = this.centers[this.centers.length - 1];
this.x = Math.min(max, Math.max(min, e.clientX - left - this.grabOffset));
const dt = Math.max(e.timeStamp - this.lastMove.t, 1) / 1000;
this.v = this.v * 0.6 + ((e.clientX - this.lastMove.x) / dt) * 0.4;
this.lastMove = { x: e.clientX, t: e.timeStamp };
this.select(this.nearest(this.x), { snap: false });
}
dragEnd(e) {
if (!this.dragging) return;
this.dragging = false;
this.bead.classList.remove('is-dragging');
if (this.bead.hasPointerCapture(e.pointerId)) this.bead.releasePointerCapture(e.pointerId);
this.select(this.nearest(this.x));
}
animate() {
if (reducedMotion.matches && !this.dragging) {
this.x = this.target;
this.v = 0;
this.render();
return;
}
if (this.running) return;
this.running = true;
let last = performance.now();
const tick = (now) => {
const dt = Math.min((now - last) / 1000, 1 / 30);
last = now;
if (this.dragging) {
this.v *= 0.85; // let the stretch relax when the pointer pauses
} else {
const force = -STIFFNESS * (this.x - this.target) - DAMPING * this.v;
this.v += force * dt;
this.x += this.v * dt;
}
const settled = !this.dragging && Math.abs(this.x - this.target) < 0.1 && Math.abs(this.v) < 1;
if (settled) {
this.x = this.target;
this.v = 0;
this.running = false;
}
this.render();
if (this.running) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
PearlNav.icons = ICONS;
window.PearlNav = PearlNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pearl Nav</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Schibsted+Grotesk:wght@400;500;600;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="pearl-nav.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
--accent: #d7f23a;
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
background:
radial-gradient(ellipse 90% 50% at 50% -5%, #ffffff 0%, rgba(255, 255, 255, 0) 70%),
linear-gradient(#f6f4f9, #e9e6ef);
color: #17141f;
font-family: "Schibsted Grotesk", system-ui, sans-serif;
}
.stage {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
text-align: center;
}
.glow {
position: absolute;
top: 62%;
left: 50%;
width: 520px;
height: 340px;
transform: translate(-50%, -20%);
color: var(--accent);
background: radial-gradient(closest-side, currentColor, transparent);
opacity: 0.45;
filter: blur(24px);
pointer-events: none;
transition: color 0.6s;
}
.eyebrow {
display: flex;
align-items: center;
gap: 8px;
color: #6b6478;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.18em;
}
.eyebrow::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(23, 20, 31, 0.07), 0 0 12px var(--accent);
transition: background-color 0.5s, box-shadow 0.5s;
}
h1 {
margin: 14px 0 0;
font-size: clamp(52px, 14vw, 88px);
font-weight: 800;
letter-spacing: -0.045em;
line-height: 1;
}
.sub {
margin: 12px 0 0;
color: #5c566a;
font-size: 15px;
}
.swap { animation: swap 0.45s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes swap {
from { opacity: 0; transform: translateY(10px); filter: blur(6px); }
}
#nav { margin-top: 40px; }
.hint {
margin: 36px 0 0;
color: #7a7388;
font-size: 13px;
}
.hint u {
color: #2a2536;
text-decoration-color: rgba(23, 20, 31, 0.35);
text-underline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
.swap { animation: none; }
}
</style>
</head>
<body>
<main class="stage">
<div class="glow" aria-hidden="true"></div>
<div class="eyebrow">PEARL</div>
<h1 id="title">Home</h1>
<p class="sub" id="sub">Everything, on one surface.</p>
<nav id="nav"></nav>
<p class="hint">Tap a tab — or <u>drag the bead</u> along the bar.</p>
</main>
<script src="pearl-nav.js"></script>
<script>
const title = document.getElementById('title');
const sub = document.getElementById('sub');
const swap = (el, text) => {
el.textContent = text;
el.classList.remove('swap');
void el.offsetWidth;
el.classList.add('swap');
};
new PearlNav(document.getElementById('nav'), {
tabs: [
{ label: 'Home', icon: 'home', color: '#d7f23a', blurb: 'Everything, on one surface.' },
{ label: 'Profile', icon: 'user', color: '#6fe0f5', blurb: 'You, at a glance.' },
{ label: 'Messages', icon: 'message', color: '#ffa62b', blurb: 'Three unread. All of them kind.' },
{ label: 'Camera', icon: 'camera', color: '#ff6f9c', blurb: 'Point, shoot, keep.' },
{ label: 'Settings', icon: 'sliders', color: '#a98bff', blurb: 'Tune it to taste.' },
],
onChange(tab) {
document.body.style.setProperty('--accent', tab.color);
if (title.textContent !== tab.label) {
swap(title, tab.label);
swap(sub, tab.blurb);
}
},
});
</script>
</body>
</html>