This is the whole build of Orb tabs, in the order it was written. Each stage below is a live page: tap it, drag across it, and from stage 2 use the arrow keys. The code under each heading is only what that stage adds; the finished source is at the end.
Four files do the work from the start: index.html for the markup, style.css for the
look, app.js for the geometry and the motion, and icons.js for the five icons. From
stage 3 there is a fifth, spring.js.
1 · A bar with a round notch
A notch that moves cannot be cut out of a <div> with CSS, so the bar is one SVG path, and
its d attribute is empty in the HTML: app.js writes it. The ball is a plain
<div>. The five tabs are real <button>s in a role="tablist",
each with an aria-label; app.js puts an icon from icons.js in each one.
The bar and the ball are hidden from assistive technology, because they are only the picture.
<nav class="orbbar" role="tablist" aria-label="Main">
<svg class="bar" viewBox="0 0 372 140" aria-hidden="true">
<defs>
<linearGradient id="bar-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" class="bar-a"/><stop offset=".4" class="bar-b"/><stop offset="1" class="bar-c"/>
</linearGradient>
</defs>
<path class="bar-shape" d="" fill="url(#bar-fill)"/>
</svg>
<div class="orb" aria-hidden="true"></div>
<button class="tab" role="tab" data-icon="home" aria-label="Home"></button>
<!-- Profile, Messages, Photos and Settings: the same button -->
</nav>
Every moving part is placed at the top left and pulled back by half its size, so a
translate(x, y) puts its centre exactly on (x, y). The ball is one radial gradient,
lit from the upper left, with inset shadows that give it a body.
/* ---- the component: 372 × 140, the bar's top edge at y = 50 ---- */
.orbbar { position: relative; width: 372px; height: 140px; touch-action: none; }
.bar { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.tab {
position: absolute; top: 0; left: 0; width: 52px; height: 52px; margin: -26px 0 0 -26px; z-index: 2;
display: grid; place-items: center; padding: 0; border: 0; background: none; cursor: pointer;
color: var(--bar-icon); border-radius: 50%;
}
/* the orb: a solid, glossy ceramic ball (no glass, no blur), lit from the upper left */
.orb {
position: absolute; top: 0; left: 0; width: 56px; height: 56px; margin: -28px 0 0 -28px; z-index: 1;
border-radius: 50%; pointer-events: none; will-change: transform;
background:
radial-gradient(circle at 34% 28%, var(--ball-hi) 0%, var(--ball-1) 20%, var(--ball-2) 48%, var(--ball-3) 82%, var(--ball-4) 100%);
box-shadow:
inset -5px -8px 14px rgba(0, 0, 0, .3),
inset 3px 4px 8px rgba(255, 255, 255, .3),
0 14px 22px -8px var(--shade),
0 4px 8px -3px rgba(0, 0, 0, .3);
}
All the geometry is a handful of numbers in component pixels. The first and last tabs sit
EDGE = 78 px in from the ends, so the notch never runs into the rounded corners; that leaves
54 px between tabs.
// ---- geometry (component px; the SVG is drawn 1:1) ----
const W = 372, TOP = 50, H = 68, CORNER = 26; // the bar: its top edge, height, corner radius
const EDGE = 78, slot = (W - 2 * EDGE) / (tabs.length - 1); // far enough in that the notch clears the corners
const tabX = (i) => EDGE + slot * i;
const ICON_Y = TOP + 32; // where icons sit in the bar
const R = 27, ORB_Y = TOP - 3; // the ball, sitting in the notch
The notch is the heart of it. It is a true circle with the same centre as the ball and a radius of
R + GAP = 34 px, so the gap around the ball is even. Where the circle meets the flat top edge it would leave two sharp corners, so each
side gets a small shoulder: a circle of radius SH = 10 px whose centre sits 10 px below the edge, so it just
touches the edge, and it touches the notch circle too.
Two circles that touch have their centres exactly NR + SH apart, which gives the shoulder's
horizontal distance dx by Pythagoras, and the point where they touch lies on the line between
the centres, at NR / (NR + SH) of the way. From there the path is three arcs: shoulder, notch,
shoulder.
// ---- the bar with its notch: one path ----
// The notch is a true circle around the ball (its radius + an even gap), joined to the top edge by two small
// round shoulders that touch both the edge and the notch circle.
const f = (x, y) => `${x.toFixed(2)} ${y.toFixed(2)}`;
const GAP = 7, NR = R + GAP, SH = 10; // notch radius, shoulder radius
function barPath(cx, cy) {
const e = cy - TOP; // the notch centre, measured from the top edge (negative = above)
const dy = SH - e; // from the notch centre down to a shoulder's centre
const dx = Math.sqrt(Math.max(0, (NR + SH) ** 2 - dy * dy));
const t = NR / (NR + SH); // where the shoulder touches the notch circle
const p1 = [cx - dx * t, cy + dy * t], p2 = [cx + dx * t, cy + dy * t];
const top = `M${CORNER} ${TOP} L${f(cx - dx, TOP)} A${SH} ${SH} 0 0 1 ${f(...p1)}`
+ ` A${NR} ${NR} 0 0 0 ${f(...p2)} A${SH} ${SH} 0 0 1 ${f(cx + dx, TOP)} L${W - CORNER} ${TOP}`;
const rest = ` A${CORNER} ${CORNER} 0 0 1 ${W} ${TOP + CORNER} L${W} ${TOP + H - CORNER}`
+ ` A${CORNER} ${CORNER} 0 0 1 ${W - CORNER} ${TOP + H} L${CORNER} ${TOP + H}`
+ ` A${CORNER} ${CORNER} 0 0 1 0 ${TOP + H - CORNER} L0 ${TOP + CORNER} A${CORNER} ${CORNER} 0 0 1 ${CORNER} ${TOP} Z`;
return top + rest;
}
For now a tap just redraws everything at the chosen tab: the notch, the ball, and the chosen icon, which
moves up into the ball and takes the dark --icon-on colour.
let index = 0;
function select(i) {
index = i;
tabs.forEach((tab, k) => tab.setAttribute('aria-selected', String(k === i)));
render();
}
tabs.forEach((tab, i) => tab.addEventListener('click', () => select(i)));
// …
function render() {
const x = tabX(index);
shape.setAttribute('d', barPath(x, ORB_Y));
orb.style.transform = `translate(${x.toFixed(2)}px, ${ORB_Y}px)`;
// each icon: in its slot, or in the ball on the chosen tab
icons.forEach((ic, k) => {
const on = k === index ? 1 : 0;
const dy = (ORB_Y - ICON_Y) * on;
ic.parentElement.style.transform = `translate(${tabX(k).toFixed(2)}px, ${(ICON_Y + dy).toFixed(2)}px)`;
ic.style.transform = `scale(${1 + 0.1 * on})`;
ic.style.color = on ? 'var(--icon-on)' : '';
});
}
select(0);
2 · The finger, and the keys
The listeners move from the buttons to the whole bar, so a finger can land anywhere on it. Pointer events
cover a mouse, a finger and a pen with one set of listeners, and setPointerCapture keeps the moves
coming even when the finger slides off the bar. A press that moves less than 8 px sideways is a tap and chooses the
tab under it; more than that is a drag, and the ball and the notch follow the finger. Letting go picks the
nearest tab.
The page scales the whole composition to fit the screen, so localX divides by that scale to
turn screen pixels back into the component's own.
let pointer = null;
function localX(e) { const r = root.getBoundingClientRect(); return (e.clientX - r.left) / (r.width / W); }
const nearest = (x) => clamp(Math.round((x - EDGE) / slot), 0, tabs.length - 1);
function showTouch(e) { touch.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`; touch.classList.add('is-on'); }
root.addEventListener('pointerdown', (e) => {
if (e.button > 0) return;
root.setPointerCapture(e.pointerId); // keep getting moves even when the finger leaves the bar
pointer = e.pointerId; startX = e.clientX; dragged = false;
showTouch(e);
});
let startX = 0, dragged = false;
root.addEventListener('pointermove', (e) => {
if (e.pointerId !== pointer) return;
showTouch(e);
if (Math.abs(e.clientX - startX) > 8) dragged = true; // a few pixels of travel: this is a drag, not a tap
if (dragged) {
x = clamp(localX(e), tabX(0), tabX(tabs.length - 1));
index = nearest(x);
render();
}
});
function release(e) {
if (e.pointerId !== pointer) return;
pointer = null; touch.classList.remove('is-on');
select(nearest(dragged ? x : localX(e)));
}
root.addEventListener('pointerup', release);
root.addEventListener('pointercancel', release);
Once the bar is driven by code, the keyboard needs its own path. Only the chosen tab is in the tab order
(tabIndex 0, the others −1), and the arrow keys move the selection and the focus with it.
// ---- selection: only the chosen tab is in the tab order; the arrow keys reach the others ----
let index = 0, x = tabX(0); // x: where the ball and the notch are
function select(i) {
index = i; x = tabX(i);
tabs.forEach((tab, k) => { tab.setAttribute('aria-selected', String(k === i)); tab.tabIndex = k === i ? 0 : -1; });
render();
}
root.addEventListener('keydown', (e) => {
const step = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
if (!step) return;
e.preventDefault(); select(clamp(index + step, 0, tabs.length - 1)); tabs[index].focus();
});
3 · Springs: the notch follows the ball
Every motion in this component comes from one small class. A spring has a value, a target and a velocity; each step it accelerates towards the target and a damper slows it down. Low damping overshoots, high damping creeps in. Unlike a CSS transition, a spring can change its target in the middle of a move without a jump.
// A spring, written from scratch: a mass on a spring with a damper.
// acceleration = stiffness × (target − value) − damping × velocity
// It is stepped in small fixed slices (1/240 s), so it moves the same on a 60 Hz and a 120 Hz screen.
// Changing the target never moves the value: an interrupted animation just bends towards the new target.
class Spring {
constructor(value = 0, { stiffness = 170, damping = 26 } = {}) {
this.value = value;
this.target = value;
this.velocity = 0;
this.stiffness = stiffness;
this.damping = damping;
}
set(options) { Object.assign(this, options); return this; }
step(dt) {
const slice = 1 / 240;
for (let t = dt; t > 0; t -= slice) {
const h = Math.min(slice, t);
const acceleration = this.stiffness * (this.target - this.value) - this.damping * this.velocity;
this.velocity += acceleration * h;
this.value += this.velocity * h;
}
return this.value;
}
resting(epsilon = 0.01) {
return Math.abs(this.target - this.value) < epsilon && Math.abs(this.velocity) < epsilon * 10;
}
}
The ball and the notch each get their own spring. ox carries the ball; nx carries
the notch on a softer one (stiffness 110 against 150), so the notch starts later and arrives later. That lag is
the whole wave: for a moment the ball runs ahead over the flat bar and the notch hurries after it. While
dragging, both targets follow the finger.
// ---- springs ----
const MOVE = { stiffness: 150, damping: 20 };
const ox = new Spring(tabX(0), MOVE); // the orb
const nx = new Spring(tabX(0), { stiffness: 110, damping: 17 }); // the notch: follows the orb, a moment late
// ---- selection ----
let index = 0, shown = -1, moving = false;
function select(i) {
if (i === index && shown === i) return;
index = i;
tabs.forEach((tab, k) => { tab.setAttribute('aria-selected', String(k === i)); tab.tabIndex = k === i ? 0 : -1; });
ox.target = tabX(i); nx.target = tabX(i);
moving = true; shown = -1;
wake();
}
function land() {
moving = false;
shown = index;
}
The animation loop runs only while something moves. The ball has landed when it is within 4 px of its tab and slower than 90 px/s; only then does the icon go into it.
// ---- the loop: it runs only while something moves ----
let running = false, last = 0;
function wake() { if (!running) { running = true; last = performance.now(); requestAnimationFrame(frame); } }
function frame(now) {
const dt = Math.min(0.032, (now - last) / 1000); last = now;
ox.step(dt); nx.step(dt);
const left = Math.abs(ox.target - ox.value);
if (moving && !pointer && left < 4 && Math.abs(ox.velocity) < 90) land();
render();
const still = ox.resting(0.05) && nx.resting(0.05);
if (pointer || moving || !still) requestAnimationFrame(frame); else running = false;
}
function render() {
shape.setAttribute('d', barPath(nx.value, ORB_Y));
orb.style.transform = `translate(${ox.value.toFixed(2)}px, ${ORB_Y}px)`;
// each icon: in its slot, or in the ball once the ball has landed on its tab
icons.forEach((ic, k) => {
const on = k === shown ? 1 : 0;
// …
});
}
4 · The surface answers the ball
Two more springs make the bar feel soft. lift is how high the ball rides: its target is the
distance left to travel, measured in tabs and capped at one, so the ball rises up to 18 px on a long trip,
half as much under a finger, and comes down as it arrives. sink is how far the notch's centre
sits below its rest. While the ball is away from the notch, or riding high, its target goes negative, down to −16 px: the notch
rises and turns shallow, as if the surface relaxed.
const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
const MOVE = calm ? { stiffness: 220, damping: 32 } : { stiffness: 150, damping: 20 };
const ox = new Spring(tabX(0), MOVE); // the orb
const nx = new Spring(tabX(0), { stiffness: 110, damping: 17 }); // the notch: follows the orb, a moment late
const sink = new Spring(0, { stiffness: 260, damping: 13 }); // how far the notch (and ball) sits below rest: sinks on landing
const lift = new Spring(0, { stiffness: 200, damping: 20 }); // the orb rises while it travels
// in frame(), after the ball and the notch have moved
const left = Math.abs(ox.target - ox.value);
lift.target = calm ? 0 : clamp(left / slot, 0, 1) * (pointer && dragged ? 0.5 : 1);
lift.step(dt);
// while the ball is away the surface relaxes (the notch rises, shallower); it settles again as the ball comes home
sink.target = -16 * clamp(Math.abs(ox.value - nx.value) / 40 + lift.value * 0.6, 0, 1);
sink.step(dt);
The landing is one push: land() adds velocity to sink, the notch dips below its
rest and, with a damping of only 13, springs back with a small wobble. The ball dips with it, but never rises
with it — Math.max(0, sink.value).
function land() {
moving = false;
if (!calm) sink.velocity += 70; // the ball lands: the notch sinks, then springs back
shown = index;
}
// in render(): the notch moves with sink, the ball rises with lift and dips with the notch
const ny = ORB_Y + sink.value; // the notch's centre
shape.setAttribute('d', barPath(nx.value, ny));
const oy = ORB_Y - 18 * lift.value + Math.max(0, sink.value); // at rest the ball sits exactly in the notch's centre
orb.style.transform = `translate(${ox.value.toFixed(2)}px, ${oy.toFixed(2)}px)`;
This is also where prefers-reduced-motion comes in: the ball moves on a stiffer, more damped
spring, does not lift, and the landing push is skipped.
5 · The icon rises into the ball
Until now the icon waited for the landing and then jumped in. Now it is carried by the ball's position.
near is 1 when the ball is right over a tab and falls to 0 when it is 0.62 of a slot away
(about 33 px), through a smoothstep ease. The icon moves that fraction of the way up to the ball,
grows by up to 10%, and mixes its colour from --bar-icon to --icon-on with
color-mix().
Only two icons take part: the one on the tab the ball leaves (from) and the one on the tab it
goes to. The icons it rolls over stay in their slots.
const ease = (u) => { u = clamp(u, 0, 1); return u * u * (3 - 2 * u); };
let index = 0, from = 0, shown = -1, moving = false;
function select(i) {
if (i === index && shown === i) return;
if (i !== index) from = index;
// …
}
// in render()
icons.forEach((ic, k) => {
// only the tab it leaves and the tab it goes to take part: the ones it rolls over stay put
const near = (k === index || k === from) ? ease(1 - Math.abs(tabX(k) - ox.value) / (slot * 0.62)) : 0;
const dy = (oy - ICON_Y) * near;
ic.parentElement.style.transform = `translate(${tabX(k).toFixed(2)}px, ${(ICON_Y + dy).toFixed(2)}px)`;
ic.style.transform = `scale(${(1 + 0.1 * near).toFixed(3)})`;
ic.style.color = near > 0.02 ? `color-mix(in srgb, var(--icon-on) ${Math.round(near * 100)}%, var(--bar-icon))` : '';
});
When the ball lands, the tab's name is written under it, one <i> per letter, each
letter starting 26 ms after the one before. The caption is hidden from assistive technology: the button's
aria-label already says the name.
function land() {
// …
caption.innerHTML = [...tabs[index].getAttribute('aria-label')].map((c, k) => `<i style="--k2:${k}">${c}</i>`).join('');
}
// in render(): under the chosen tab, visible only once the ball has landed
caption.style.left = `${tabX(index)}px`; caption.style.top = `${CAPTION_Y}px`;
caption.style.opacity = shown === index ? 1 : 0;
.caption { position: absolute; top: 0; left: 0; margin: 0; transform: translate(-50%, -50%); font-size: 11.5px; font-weight: 600; letter-spacing: .04em; color: var(--caption); white-space: nowrap; pointer-events: none; z-index: 2; }
.caption i { display: inline-block; font-style: normal; animation: letter .45s cubic-bezier(.3, 1.4, .5, 1) both; animation-delay: calc(var(--k2) * 26ms); }
@keyframes letter { from { opacity: 0; transform: translateY(6px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }
@media (prefers-reduced-motion: reduce) { .touch { transition: none; } .caption i { animation: none; } }
6 · Light, and five palettes
The last stage is polish, and none of it changes the motion. A blurred shadow sits under the bar. A second path traces only the top edge — along the bar, down into the notch and out again — and is stroked with a thin light line, so the rim catches the light wherever the notch goes. The ball gets a hot glint at its upper left and a soft warm glow at its base.
<div class="shade" aria-hidden="true"></div>
<svg class="bar" viewBox="0 0 372 140" aria-hidden="true">
<!-- … the same gradient -->
<path class="bar-shape" d="" fill="url(#bar-fill)"/>
<path class="bar-rim" d=""/> <!-- light along the top edge, down into the notch and out again -->
</svg>
<div class="orb" aria-hidden="true"><i class="glint"></i><i class="caustic"></i></div>
function barPath(cx, cy) {
// …
return [top + rest, top];
}
// in render(): the whole shape is filled; only its top edge is stroked
const [full, top] = barPath(nx.value, ny);
shape.setAttribute('d', full); rim.setAttribute('d', top);
.shade { position: absolute; left: 22px; right: 22px; top: 76px; height: 56px; border-radius: 40px; background: var(--shade); filter: blur(22px); }
.bar-rim { fill: none; stroke: var(--rim); stroke-width: 1.2; }
.orb .glint { position: absolute; top: 8px; left: 12px; width: 16px; height: 9px; border-radius: 50%; background: rgba(255, 255, 255, .9); transform: rotate(-30deg); filter: blur(1px); }
.orb .caustic { position: absolute; bottom: 4px; left: 50%; width: 28px; height: 8px; margin-left: -14px; border-radius: 50%; background: radial-gradient(closest-side, rgba(255, 240, 200, .6), rgba(255, 240, 200, 0)); }
Every colour in style.css comes from variables, and the finished file holds five sets of them.
The palette is picked from the address: add ?palette=midnight, snow,
cobalt, volt or plum; without it the bar is volt.
document.body.dataset.palette = new URLSearchParams(location.search).get('palette') || 'volt';
[data-palette="volt"] { /* black and graphite, an acid-lime ball */
--bg-a: #1F2022; --bg-b: #111213; --bg-c: #080809; --glow: rgba(200, 255, 60, .12);
--bar-a: #34363A; --bar-b: #202225; --bar-c: #151618; --rim: rgba(255, 255, 255, .16);
--ball-hi: #FBFFE0; --ball-1: #E4FF7A; --ball-2: #C6F53C; --ball-3: #8FBF12; --ball-4: #5C8008;
--icon-on: #1A2204; --bar-icon: #7D8086; --caption: #C6F53C;
--ink: #F4F6EE; --ink-2: #83878C; --accent: #C6F53C; --chip-text: #1A2204; --shade: rgba(0, 0, 0, .6);
}
Make it yours
- Colours are variables: pick one of the five palettes with
?palette=, or copy a[data-palette]block, rename it and change the values — the bar, the ball, the icons and the caption all follow. - Icons live in
icons.js, on a 24 × 24 grid; add one and give a button itsdata-icon. - The shape is a few numbers in
app.js:GAPfor the space around the ball,SHfor how round the shoulders are,EDGEfor how far in the first and last tabs sit. - The feel is in the springs:
MOVEfor the ball, thenxspring for how late the notch follows, and the damping ofsinkfor how long the landing wobbles. - Accessibility is already there: real buttons with
aria-labelandaria-selected, only the chosen tab in the tab order, arrow keys, andprefers-reduced-motiongives the ball a calmer spring, turns off the lift and the landing dip, and writes the name without animating it.
Draw the shape with code. Once the bar
and its notch are one path rebuilt from a few numbers every frame, most of what follows is a spring feeding one of
those numbers: nx moves the notch, sink deepens it or lets it relax, and the path
does the rest.