Library / Tutorial

Orb tabs,
step by step.

A dark tab bar with a round notch in its top edge and a glossy ball sitting in it. Choose a tab and the ball rolls there, the notch follows it along the surface like a wave, a moment late, and the tab's icon rises into the ball. We build it in six stages, and every stage runs on its own — try each one before you read the next.

6Stages
4Springs
0Libraries

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.

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

style.css — the bar, the tabs and the ball
/* ---- 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.

app.js — the numbers
// ---- 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.

app.js — the bar and its notch, as one path
// ---- 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.

app.js — select and draw
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);
Stage 1 · tap a tabOpen ↗

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.

app.js — tap, drag, let go
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.

app.js — the selection and the arrow keys
// ---- 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();
});
Stage 2 · tap, or drag the ballOpen ↗

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.

spring.js — 30 lines
// 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.

app.js — two springs, and landing
// ---- 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.

app.js — the loop
// ---- 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;
    // …
  });
}
Stage 3 · watch the notch followOpen ↗

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.

app.js — lift and sink
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).

app.js — the landing
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.

Stage 4 · the landingOpen ↗

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.

app.js — near, from and the icon
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.

app.js — the name under the notch
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;
style.css — the letters
.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; } }
Stage 5 · the icon risesOpen ↗

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.

index.html — the shadow, the rim and the ball's highlights
<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>
app.js — the rim is the top of the same path
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);
style.css — the finish
.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.

app.js — the palette comes from the address
document.body.dataset.palette = new URLSearchParams(location.search).get('palette') || 'volt';
style.css — one of the five palettes
[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);
}
Stage 6 · the finished barOpen ↗

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 its data-icon.
  • The shape is a few numbers in app.js: GAP for the space around the ball, SH for how round the shoulders are, EDGE for how far in the first and last tabs sit.
  • The feel is in the springs: MOVE for the ball, the nx spring for how late the notch follows, and the damping of sink for how long the landing wobbles.
  • Accessibility is already there: real buttons with aria-label and aria-selected, only the chosen tab in the tab order, arrow keys, and prefers-reduced-motion gives the ball a calmer spring, turns off the lift and the landing dip, and writes the name without animating it.
The one idea worth keeping

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.