Elastic Nav Indicator
A tab and underline navigation bar whose shared pill indicator slides between items and elastically squashes, stretches, and overshoots as it travels, adapting its width to every label with spring physics and full keyboard support.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
background:
radial-gradient(1200px 700px at 80% -10%, rgba(139, 92, 246, 0.10), transparent 60%),
radial-gradient(900px 600px at -10% 20%, rgba(34, 211, 238, 0.07), transparent 55%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
min-height: 100vh;
}
.wrap {
max-width: 780px;
margin: 0 auto;
padding: clamp(24px, 5vw, 56px) clamp(16px, 4vw, 32px) 64px;
display: flex;
flex-direction: column;
gap: 26px;
}
/* ---------- header ---------- */
.head { display: flex; flex-direction: column; gap: 10px; }
.eyebrow {
margin: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent-2);
}
h1 {
margin: 0;
font-size: clamp(26px, 5vw, 38px);
font-weight: 700;
letter-spacing: -0.02em;
}
.lede { margin: 0; color: var(--muted); max-width: 60ch; font-size: 15px; }
/* ---------- stage ---------- */
.stage {
background: linear-gradient(180deg, var(--surface), var(--surface-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: clamp(20px, 4vw, 34px);
display: flex;
flex-direction: column;
gap: 30px;
}
.demo-block { display: flex; flex-direction: column; gap: 10px; }
.demo-tag {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
/* ---------- nav core ---------- */
.nav {
position: relative;
display: flex;
align-items: center;
gap: 4px;
width: max-content;
max-width: 100%;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.nav::-webkit-scrollbar { display: none; }
.nav__item {
position: relative;
z-index: 1;
appearance: none;
border: 0;
background: transparent;
color: var(--muted);
font: inherit;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
transition: color 0.22s ease;
border-radius: var(--r-sm);
}
.nav__item:hover { color: var(--ink); }
.nav__item[aria-selected="true"] { color: var(--ink); }
.nav__item:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
.nav__indicator {
position: absolute;
top: 0;
left: 0;
z-index: 0;
pointer-events: none;
/* transform origin is centered; JS sets translate + scale */
transform-origin: center center;
will-change: transform;
}
/* pill variant */
.nav--pill {
padding: 6px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--line);
border-radius: 999px;
}
.nav--pill .nav__item { padding: 9px 18px; }
.nav--pill .nav__indicator {
height: 0; /* height set by JS to item height */
background: linear-gradient(180deg, rgba(139, 92, 246, 0.95), rgba(124, 58, 237, 0.95));
border-radius: 999px;
box-shadow:
0 6px 20px rgba(139, 92, 246, 0.40),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
/* underline variant */
.nav--underline {
gap: 6px;
border-bottom: 1px solid var(--line);
padding-bottom: 0;
}
.nav--underline .nav__item { padding: 10px 14px; }
.nav--underline .nav__indicator {
height: 3px;
border-radius: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
box-shadow: 0 0 14px rgba(34, 211, 238, 0.55);
}
.hint {
margin: 0;
color: var(--muted);
font-size: 13px;
}
kbd {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
border-radius: 6px;
padding: 1px 6px;
color: var(--ink);
}
/* ---------- panel / controls ---------- */
.panel {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: clamp(18px, 4vw, 26px);
display: flex;
flex-direction: column;
gap: 18px;
}
.panel__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel h2 { margin: 0; font-size: 15px; font-weight: 600; }
.btn {
appearance: none;
cursor: pointer;
font: inherit;
font-size: 13px;
font-weight: 600;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
padding: 7px 14px;
transition: border-color 0.18s ease, background 0.18s ease;
}
.btn:hover { border-color: var(--accent); background: #23252f; }
.btn:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 2px; }
.controls {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
.ctl { display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
.ctl__row {
display: flex;
justify-content: space-between;
align-items: baseline;
color: var(--muted);
font-weight: 500;
}
.ctl__row output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--ink);
}
input[type="range"] {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 4px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
border: 2px solid #0c0d10;
box-shadow: 0 0 0 1px var(--accent);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border: 2px solid #0c0d10;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
input[type="range"]:focus-visible { outline: 2px solid var(--accent-2); outline-offset: 4px; }
.toggle {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--muted);
cursor: pointer;
}
.toggle input { width: 16px; height: 16px; accent-color: var(--accent); }
.readout { margin: 0; font-size: 13px; color: var(--muted); }
.mono, .readout .mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
color: var(--ink);
}
/* ---------- note ---------- */
.note {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 18px 20px;
background: rgba(255, 255, 255, 0.02);
}
.note h2 { margin: 0 0 8px; font-size: 14px; font-weight: 600; }
.note p { margin: 0; color: var(--muted); font-size: 14px; }
.note code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12.5px;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.08);
padding: 1px 5px;
border-radius: 5px;
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
.controls { grid-template-columns: 1fr; }
.nav--pill .nav__item { padding: 8px 13px; }
h1 { font-size: 25px; }
}
@media (prefers-reduced-motion: reduce) {
.nav__item { transition: none; }
}/* Elastic Nav Indicator
A shared-element pill/underline that springs between nav items and
squashes/stretches in its direction of travel. Vanilla JS, no deps. */
(function () {
"use strict";
var mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)");
/* ---- global tunables (driven by the controls) ---- */
var config = {
stiffness: 220, // spring constant k
damping: 18, // damping coefficient c
stretch: 1.0, // multiplier on velocity->squash mapping
forceReduce: false
};
function reducedMotion() {
return config.forceReduce || mqReduce.matches;
}
/* Critically-ish damped spring integrator for a single scalar. */
function Spring(value) {
this.value = value;
this.target = value;
this.vel = 0;
}
Spring.prototype.set = function (v) { this.value = this.target = v; this.vel = 0; };
Spring.prototype.step = function (dt) {
// semi-implicit Euler with sub-stepping for stability at high stiffness
var steps = 4;
var h = dt / steps;
for (var i = 0; i < steps; i++) {
var force = -config.stiffness * (this.value - this.target) - config.damping * this.vel;
this.vel += force * h;
this.value += this.vel * h;
}
return this.value;
};
function NavController(nav) {
this.nav = nav;
this.indicator = nav.querySelector(".nav__indicator");
this.items = Array.prototype.slice.call(nav.querySelectorAll(".nav__item"));
this.activeIndex = Math.max(0, this.items.findIndex(function (b) {
return b.getAttribute("aria-selected") === "true";
}));
this.x = new Spring(0);
this.w = new Spring(0);
this.raf = null;
this.lastT = 0;
this.reportVel = 0;
this.reportSquash = 1;
this.bind();
// measure once fonts/layout settle
this.snapToActive();
this.onResize = this.onResize.bind(this);
window.addEventListener("resize", this.onResize);
if (document.fonts && document.fonts.ready) {
var self = this;
document.fonts.ready.then(function () { self.snapToActive(); });
}
}
NavController.prototype.metrics = function (index) {
var item = this.items[index];
var navRect = this.nav.getBoundingClientRect();
var r = item.getBoundingClientRect();
return {
x: r.left - navRect.left + this.nav.scrollLeft,
y: r.top - navRect.top,
w: r.width,
h: r.height
};
};
NavController.prototype.snapToActive = function () {
var m = this.metrics(this.activeIndex);
this.x.set(m.x);
this.w.set(m.w);
this.layoutIndicatorBox(m);
this.paint(m.x, m.w, 1, 1);
};
// Set the static box (height / vertical position) for the current variant.
NavController.prototype.layoutIndicatorBox = function (m) {
var variant = this.nav.getAttribute("data-variant");
if (variant === "underline") {
// indicator sits at the bottom edge; fixed 3px height from CSS
this.indicator.style.top = (m.y + m.h - 3) + "px";
} else {
this.indicator.style.top = m.y + "px";
this.indicator.style.height = m.h + "px";
}
};
NavController.prototype.paint = function (x, w, sx, sy) {
// translate by the animated left edge, then scale about center.
// Because transform-origin is center, we translate to center-x first.
var cx = x + w / 2;
var el = this.indicator;
el.style.width = w + "px";
el.style.left = "0px";
// move so element's own center lands at cx (element left currently 0)
var tx = cx - w / 2;
el.style.transform =
"translateX(" + tx + "px) scale(" + sx + "," + sy + ")";
};
NavController.prototype.animateTo = function (index) {
var m = this.metrics(index);
this.layoutIndicatorBox(m);
if (reducedMotion()) {
this.x.set(m.x);
this.w.set(m.w);
this.paint(m.x, m.w, 1, 1);
this.reportVel = 0; this.reportSquash = 1;
publish();
return;
}
this.x.target = m.x;
this.w.target = m.w;
if (!this.raf) {
this.lastT = performance.now();
var self = this;
this.raf = requestAnimationFrame(function (t) { self.tick(t); });
}
};
NavController.prototype.tick = function (t) {
var dt = Math.min(0.05, (t - this.lastT) / 1000) || 0.016;
this.lastT = t;
var x = this.x.step(dt);
var w = this.w.step(dt);
// velocity -> squash. positive = moving right.
var v = this.x.vel; // px/s
// map speed to a scale delta; clamp so it never inverts.
var k = 0.00035 * config.stretch;
var sx = 1 + Math.min(0.55, Math.abs(v) * k);
// volume-ish preservation: counter-scale the cross axis
var sy = 1 / (1 + (sx - 1) * 0.65);
this.reportVel = v;
this.reportSquash = sx;
this.paint(x, w, sx, sy);
var settled =
Math.abs(x - this.x.target) < 0.15 &&
Math.abs(w - this.w.target) < 0.15 &&
Math.abs(this.x.vel) < 3 && Math.abs(this.w.vel) < 3;
if (settled) {
this.x.set(this.x.target);
this.w.set(this.w.target);
this.paint(this.x.value, this.w.value, 1, 1);
this.reportVel = 0; this.reportSquash = 1;
this.raf = null;
publish();
return;
}
publish();
var self = this;
this.raf = requestAnimationFrame(function (tt) { self.tick(tt); });
};
NavController.prototype.select = function (index, focus) {
if (index === this.activeIndex) { if (focus) this.items[index].focus(); return; }
this.items[this.activeIndex].setAttribute("aria-selected", "false");
this.items[this.activeIndex].setAttribute("tabindex", "-1");
this.activeIndex = index;
var item = this.items[index];
item.setAttribute("aria-selected", "true");
item.setAttribute("tabindex", "0");
if (focus) item.focus();
// keep the active item in view for the scrollable case
if (item.scrollIntoView) item.scrollIntoView({ inline: "nearest", block: "nearest" });
this.animateTo(index);
};
NavController.prototype.onResize = function () {
// stop any spring and re-snap to avoid stale pixel coords
if (this.raf) { cancelAnimationFrame(this.raf); this.raf = null; }
this.snapToActive();
};
NavController.prototype.bind = function () {
var self = this;
this.items.forEach(function (item, i) {
item.addEventListener("click", function () { self.select(i, true); });
});
this.nav.addEventListener("keydown", function (e) {
var last = self.items.length - 1;
var next = null;
switch (e.key) {
case "ArrowRight":
case "ArrowDown": next = Math.min(last, self.activeIndex + 1); break;
case "ArrowLeft":
case "ArrowUp": next = Math.max(0, self.activeIndex - 1); break;
case "Home": next = 0; break;
case "End": next = last; break;
default: return;
}
e.preventDefault();
self.select(next, true);
});
};
/* ---------- controls wiring ---------- */
var controllers = Array.prototype.map.call(
document.querySelectorAll(".nav"),
function (nav) { return new NavController(nav); }
);
var velOut = document.getElementById("vel");
var squashOut = document.getElementById("squash");
function publish() {
// report the liveliest controller
var v = 0, sq = 1;
controllers.forEach(function (c) {
if (Math.abs(c.reportVel) > Math.abs(v)) v = c.reportVel;
if (c.reportSquash > sq) sq = c.reportSquash;
});
if (velOut) velOut.textContent = Math.round(v);
if (squashOut) squashOut.textContent = sq.toFixed(2) + "×";
}
function bindRange(id, outId, key, fmt) {
var input = document.getElementById(id);
var out = document.getElementById(outId);
function apply() {
config[key] = parseFloat(input.value);
out.textContent = fmt ? fmt(config[key]) : input.value;
}
input.addEventListener("input", apply);
apply();
return input;
}
var stiffEl = bindRange("stiff", "outStiff", "stiffness");
var dampEl = bindRange("damp", "outDamp", "damping");
var stretchEl = bindRange("stretch", "outStretch", "stretch", function (v) {
return v.toFixed(1);
});
var reduceEl = document.getElementById("reduce");
reduceEl.addEventListener("change", function () {
config.forceReduce = reduceEl.checked;
// snap immediately so state is honest when toggled on
if (config.forceReduce) {
controllers.forEach(function (c) {
if (c.raf) { cancelAnimationFrame(c.raf); c.raf = null; }
c.snapToActive();
});
publish();
}
});
document.getElementById("reset").addEventListener("click", function () {
stiffEl.value = 220; stiffEl.dispatchEvent(new Event("input"));
dampEl.value = 18; dampEl.dispatchEvent(new Event("input"));
stretchEl.value = 1.0; stretchEl.dispatchEvent(new Event("input"));
reduceEl.checked = false;
config.forceReduce = false;
});
publish();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Elastic Nav Indicator</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=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="head">
<p class="eyebrow">UI · Shared-element motion</p>
<h1>Elastic Nav Indicator</h1>
<p class="lede">
One shared pill slides between tabs and elastically squashes, stretches, and
overshoots as it travels — adapting its width to every label. Spring physics,
two variants, full keyboard support.
</p>
</header>
<section class="stage" aria-label="Demo">
<!-- Pill variant -->
<div class="demo-block">
<span class="demo-tag">Pill variant</span>
<nav
class="nav nav--pill"
data-variant="pill"
role="tablist"
aria-label="Workspace sections"
>
<span class="nav__indicator" aria-hidden="true"></span>
<button class="nav__item" role="tab" aria-selected="true" tabindex="0">Overview</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Analytics</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Automations</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Team</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Settings</button>
</nav>
</div>
<!-- Underline variant -->
<div class="demo-block">
<span class="demo-tag">Underline variant</span>
<nav
class="nav nav--underline"
data-variant="underline"
role="tablist"
aria-label="Document tabs"
>
<span class="nav__indicator" aria-hidden="true"></span>
<button class="nav__item" role="tab" aria-selected="true" tabindex="0">Editor</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Preview</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">History</button>
<button class="nav__item" role="tab" aria-selected="false" tabindex="-1">Comments</button>
</nav>
</div>
<p class="hint">
Click a tab, or focus one and use <kbd>←</kbd> <kbd>→</kbd> <kbd>Home</kbd>
<kbd>End</kbd>. Watch the indicator squash toward its direction of travel and
overshoot before it settles.
</p>
</section>
<section class="panel" aria-label="Physics controls">
<div class="panel__head">
<h2>Spring physics</h2>
<button id="reset" class="btn" type="button">Reset</button>
</div>
<div class="controls">
<label class="ctl">
<span class="ctl__row"><span>Stiffness</span><output id="outStiff">220</output></span>
<input id="stiff" type="range" min="60" max="480" step="5" value="220" />
</label>
<label class="ctl">
<span class="ctl__row"><span>Damping</span><output id="outDamp">18</output></span>
<input id="damp" type="range" min="6" max="40" step="1" value="18" />
</label>
<label class="ctl">
<span class="ctl__row"><span>Stretch</span><output id="outStretch">1.0</output></span>
<input id="stretch" type="range" min="0" max="2.5" step="0.1" value="1.0" />
</label>
</div>
<label class="toggle">
<input id="reduce" type="checkbox" />
<span>Force reduced motion (instant, no deform)</span>
</label>
<p class="readout">
Live velocity <span id="vel" class="mono">0</span> px/s ·
squash <span id="squash" class="mono">1.00×</span>
</p>
</section>
<section class="note">
<h2>How it works</h2>
<p>
A tiny spring integrator advances a virtual <code>x</code> and <code>width</code>
toward the active tab each frame. The indicator's travel velocity is mapped to a
horizontal <code>scaleX</code> (squash) with a compensating vertical scale, so the
element physically deforms in its direction of motion, then the spring's natural
overshoot supplies the elastic settle. Both variants drive the identical engine.
</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>The Elastic Nav Indicator is a single shared-element highlight that travels across a row of tabs, adapting its width to whatever label is active. Instead of a rigid slide, the indicator squashes along its direction of travel while it accelerates, stretches to bridge the gap between the old and new item, then overshoots and settles with a spring — the classic anticipation-and-follow-through you see in polished mobile navigation.
Under the hood a small spring integrator tracks a virtual position and width toward the target item. The travel velocity is mapped to a horizontal scale (squash) and a small vertical counter-scale, so the pill physically deforms in the direction it is moving. Two visual variants — a filled pill and a thin underline — share the exact same motion engine, proving the technique is purely presentational. Live controls let you tune stiffness, damping, and stretch intensity to feel the physics change in real time.
Interaction is fully keyboard driven: arrow keys move between tabs following the WAI-ARIA tablist pattern, Home and End jump to the ends, and focus is always visible. The component respects prefers-reduced-motion, collapsing the elastic travel into an instant, non-deforming move so motion-sensitive users still get a clear active state without the bounce.