№ 008
Ember Nav
A liquid-glass tab bar with a glowing amber orb on a spring; the bar bulges around it like liquid.
- Version
- 1.0.0
- Download
- 10 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, liquid glass, drag
Usage
Copy ember-nav.css and ember-nav.js (and assets/, if there is one) into your project, then:
new EmberNav(element, { tabs: [{ label, icon }], active: 2, onChange(tab, index) {} })
`icon` is a key of EmberNav.icons or a raw SVG string.
A liquid-glass tab bar: a glowing amber orb rides on a spring, and the bar's outline is a smooth
union of the pill and a circle around the orb, so the edges bulge around it like liquid.Source
/* Ember nav — liquid glass tab bar with a glowing amber orb. Sizes come from the bar's width (set in JS). */
.en {
position: relative;
width: 100%;
max-width: 380px;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
touch-action: pan-y;
cursor: pointer;
}
.en.is-dragging { cursor: grabbing; }
/* warm pool of light the orb throws onto the page, spreading sideways from the bar's bottom edge */
.en-glow {
position: absolute;
left: 0;
top: 0;
width: 62%;
aspect-ratio: 3.4;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 236, 190, 0.95), rgba(255, 186, 96, 0.75) 32%, rgba(255, 150, 70, 0.28) 66%, rgba(255, 140, 60, 0));
filter: blur(9px);
opacity: 0.75;
pointer-events: none;
will-change: transform;
}
/* softer, wider halo around the orb's side of the bar */
.en-halo {
position: absolute;
left: 0;
top: 0;
width: 70%;
aspect-ratio: 1.9;
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 170, 90, 0.42), rgba(255, 160, 90, 0.12) 60%, rgba(255, 160, 90, 0));
filter: blur(14px);
opacity: 0.7;
pointer-events: none;
will-change: transform;
}
/* hot flare on the crest of the top bulge */
.en-flare {
position: absolute;
left: 0;
top: 0;
width: calc(var(--en-orb) * 1.9);
height: calc(var(--en-orb) * 0.55);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 255, 248, 1), rgba(255, 236, 190, 0.95) 22%, rgba(255, 190, 100, 0.55) 48%, rgba(255, 160, 70, 0.15) 75%, rgba(255, 160, 70, 0));
filter: blur(2px);
opacity: 0.78;
pointer-events: none;
will-change: transform;
}
.en-shape {
position: absolute;
top: 0;
overflow: visible;
pointer-events: none;
filter:
drop-shadow(0 18px 22px rgba(46, 26, 70, 0.32))
drop-shadow(0 4px 6px rgba(30, 18, 44, 0.3));
}
.en-tabs {
position: absolute;
inset: 0;
}
.en-tab {
position: absolute;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: none;
color: #d6cfe8;
cursor: pointer;
}
.en-tab:focus-visible { outline: 2px solid rgba(255, 180, 106, 0.8); outline-offset: -6px; }
.en-icon {
display: grid;
width: var(--en-icon);
height: var(--en-icon);
transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.en-icon svg { width: 100%; height: 100%; filter: drop-shadow(0 0 4px rgba(190, 175, 255, 0.18)); }
/* glowing smear behind the moving orb */
.en-trail {
position: absolute;
left: 0;
top: 0;
width: calc(var(--en-orb) * 1.1);
height: calc(var(--en-orb) * 0.8);
border-radius: 50%;
background: radial-gradient(closest-side, rgba(255, 170, 80, 0.85), rgba(255, 130, 40, 0));
filter: blur(6px);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.en-orb {
position: absolute;
left: 0;
top: 0;
width: var(--en-orb);
height: var(--en-orb);
pointer-events: none;
will-change: transform;
}
.en-ball {
position: absolute;
inset: 0;
border-radius: 50%;
background:
radial-gradient(ellipse 58% 34% at 50% 16%, rgba(255, 246, 220, 0.85), rgba(255, 230, 180, 0.12) 70%, transparent 72%),
radial-gradient(circle at 50% 108%, rgba(255, 214, 140, 0.95), rgba(255, 190, 100, 0) 46%),
radial-gradient(circle at 50% 58%, #ffc25c 0%, #ff9d2f 30%, #f7801b 58%, #e0620f 82%, #b84a0c 100%);
box-shadow:
inset 0 0 0 1.5px rgba(255, 200, 130, 0.55),
inset 0 -6px 12px rgba(255, 210, 140, 0.45),
inset 0 4px 10px rgba(255, 255, 255, 0.25),
0 0 12px rgba(255, 176, 80, 0.75),
0 0 28px rgba(255, 150, 50, 0.42),
0 0 56px rgba(255, 130, 40, 0.18);
will-change: transform;
}
.en-glyph {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #fff;
}
.en-glyph svg {
width: calc(var(--en-orb) * 0.46);
height: calc(var(--en-orb) * 0.46);
stroke-width: 1.9;
filter: drop-shadow(0 0 3px rgba(255, 255, 255, 0.8)) drop-shadow(0 0 8px rgba(255, 200, 120, 0.9));
}
.en-glyph.is-pop svg { animation: en-pop 0.42s cubic-bezier(0.3, 1.7, 0.5, 1); }
@keyframes en-pop {
from { opacity: 0.2; transform: scale(0.55); }
}
@media (prefers-reduced-motion: reduce) {
.en-glyph.is-pop svg { animation: none; }
.en-icon { transition: none; }
} /* Ember nav — usage:
* new EmberNav(element, { tabs: [{ label, icon }], active: 2, onChange(tab, index) {} })
* `icon` is a key of EmberNav.icons or a raw SVG string.
*
* A liquid-glass tab bar: a glowing amber orb rides on a spring, and the bar's outline is a smooth
* union of the pill and a circle around the orb, so the edges bulge around it like liquid.
*/
(function () {
const ASPECT = 5.1; // bar width / height
const ORB = 0.75; // orb radius, as a share of the bar's half-height
const BULGE = 1.25; // radius of the liquid bulge around the orb (× half-height)
const BLEND = 0.45; // softness of the join between bar and bulge (× half-height)
const EDGE = 0.125; // first/last tab centre, as a share of the width
const STIFFNESS = 260;
const DAMPING = 21;
const DRAG_STIFFNESS = 900;
const DRAG_DAMPING = 48;
const svg = (body) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const ICONS = {
grid: svg('<rect x="4" y="4" width="6.5" height="6.5" rx="1.8"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.8"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.8"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.8"/>'),
heart: svg('<path d="M12 20s-7.6-4.6-7.6-10.2A4.3 4.3 0 0 1 12 7.4a4.3 4.3 0 0 1 7.6 2.4C19.6 15.4 12 20 12 20z"/>'),
home: svg('<path d="M4 11 12 4.2 20 11v8.3a1.2 1.2 0 0 1-1.2 1.2H14.8v-5.6a.9.9 0 0 0-.9-.9h-3.8a.9.9 0 0 0-.9.9v5.6H5.2A1.2 1.2 0 0 1 4 19.3z"/>'),
bell: svg('<path d="M6.2 16.6V11a5.8 5.8 0 0 1 11.6 0v5.6l1.6 1.8H4.6z"/><path d="M10.2 20.4a1.9 1.9 0 0 0 3.6 0"/><path d="M12 3.4v1.8"/>'),
user: svg('<circle cx="12" cy="8.2" r="3.9"/><path d="M4.6 20.2c.7-4 3.7-6.2 7.4-6.2s6.7 2.2 7.4 6.2z"/>'),
};
let uid = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// polynomial smooth maximum: a soft, liquid-looking union of two profiles
const smax = (a, b, k) => {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.max(a, b) + h * h * k * 0.25;
};
class EmberNav {
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.hover = null; // pointer x while hovering (for the magnetic pull)
this.running = false;
this.shown = active; // tab whose icon is inside the orb
this.build();
this.layout();
this.x = this.target = this.centers[active];
this.select(active, { silent: true });
this.render();
new ResizeObserver(() => this.layout()).observe(root);
}
icon(key) {
return ICONS[key] || key;
}
build() {
const id = ++uid;
this.ids = { body: `en-body-${id}`, gloss: `en-gloss-${id}`, rim: `en-rim-${id}`, hot: `en-hot-${id}`, spill: `en-spill-${id}`, clip: `en-clip-${id}`, blur: `en-blur-${id}`, soft: `en-soft-${id}` };
const ids = this.ids;
this.root.classList.add('en');
this.root.innerHTML = `
<div class="en-halo" aria-hidden="true"></div>
<div class="en-glow" aria-hidden="true"></div>
<svg class="en-shape" aria-hidden="true">
<defs>
<linearGradient id="${ids.body}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2f2840"/>
<stop offset="0.45" stop-color="#17141f"/>
<stop offset="1" stop-color="#0d0b13"/>
</linearGradient>
<linearGradient id="${ids.gloss}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.16"/>
<stop offset="0.32" stop-color="#fff" stop-opacity="0.03"/>
<stop offset="0.7" stop-color="#fff" stop-opacity="0"/>
<stop offset="1" stop-color="#8c78d8" stop-opacity="0.12"/>
</linearGradient>
<linearGradient id="${ids.rim}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
<stop offset="0" stop-color="#a99de2"/>
<stop offset="0.3" stop-color="#a99de2"/>
<stop offset="0.45" stop-color="#ffc07a"/>
<stop offset="0.55" stop-color="#ffc07a"/>
<stop offset="0.7" stop-color="#a99de2"/>
<stop offset="1" stop-color="#a99de2"/>
</linearGradient>
<linearGradient id="${ids.hot}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
<stop offset="0" stop-color="#ff9a3c" stop-opacity="0"/>
<stop offset="0.3" stop-color="#ff9a3c" stop-opacity="0"/>
<stop offset="0.45" stop-color="#ffab55" stop-opacity="1"/>
<stop offset="0.55" stop-color="#ffab55" stop-opacity="1"/>
<stop offset="0.7" stop-color="#ff9a3c" stop-opacity="0"/>
<stop offset="1" stop-color="#ff9a3c" stop-opacity="0"/>
</linearGradient>
<radialGradient id="${ids.spill}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="10">
<stop offset="0" stop-color="#ff9c3a" stop-opacity="0.26"/>
<stop offset="0.45" stop-color="#ff8a2a" stop-opacity="0.07"/>
<stop offset="1" stop-color="#ff8a2a" stop-opacity="0"/>
</radialGradient>
<clipPath id="${ids.clip}"><path class="en-clip-path"/></clipPath>
<filter id="${ids.blur}" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="2.2"/></filter>
<filter id="${ids.soft}" x="-10%" y="-60%" width="120%" height="220%"><feGaussianBlur stdDeviation="3.5"/></filter>
</defs>
<path class="en-body" fill="url(#${ids.body})"/>
<g clip-path="url(#${ids.clip})">
<circle class="en-spill" fill="url(#${ids.spill})"/>
<circle class="en-socket" fill="rgba(3,2,7,0.92)" filter="url(#${ids.blur})"/>
<circle class="en-socket-rim" fill="none" stroke="rgba(255,182,100,0.7)" stroke-width="2" filter="url(#${ids.blur})"/>
<path class="en-gloss" fill="url(#${ids.gloss})"/>
</g>
<path class="en-inner" fill="none" stroke="rgba(214,204,255,0.2)" stroke-width="1.4"/>
<path class="en-edge" fill="none" stroke="rgba(255,255,255,0.32)" stroke-width="1"/>
<path class="en-hot" fill="none" stroke="url(#${ids.hot})" stroke-width="5" opacity="0.8" filter="url(#${ids.soft})"/>
<path class="en-rim" fill="none" stroke="url(#${ids.rim})" stroke-width="2.6"/>
</svg>
<div class="en-flare" aria-hidden="true"></div>
<div class="en-tabs" role="tablist" aria-label="Main"></div>
<div class="en-trail" aria-hidden="true"></div>
<div class="en-orb" aria-hidden="true"><div class="en-ball"><span class="en-glyph"></span></div></div>`;
const q = (s) => this.root.querySelector(s);
this.svg = q('.en-shape');
this.el = {
body: q('.en-body'), clip: q('.en-clip-path'), gloss: q('.en-gloss'), inner: q('.en-inner'), edge: q('.en-edge'), rim: q('.en-rim'), hot: q('.en-hot'),
spill: q('.en-spill'), socket: q('.en-socket'), socketRim: q('.en-socket-rim'), glow: q('.en-glow'), halo: q('.en-halo'),
flare: q('.en-flare'), trail: q('.en-trail'), orb: q('.en-orb'),
ball: q('.en-ball'), glyph: q('.en-glyph'),
rimGrad: q(`#${ids.rim}`), rimStops: [...q(`#${ids.rim}`).querySelectorAll('stop')], spillGrad: q(`#${ids.spill}`),
hotGrad: q(`#${ids.hot}`), hotStops: [...q(`#${ids.hot}`).querySelectorAll('stop')],
};
const list = q('.en-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'en-tab';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', tab.label);
b.innerHTML = `<span class="en-icon">${this.icon(tab.icon)}</span>`;
b.addEventListener('click', (e) => {
if (e.detail === 0) this.select(i); // keyboard activation; pointer taps are handled below
});
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 = clamp(next, 0, last);
this.select(i);
this.buttons[i].focus();
});
this.root.addEventListener('pointerdown', (e) => this.down(e));
this.root.addEventListener('pointermove', (e) => this.move(e));
this.root.addEventListener('pointerup', (e) => this.up(e));
this.root.addEventListener('pointercancel', (e) => this.up(e));
this.root.addEventListener('pointerleave', () => {
if (!this.dragging) { this.hover = null; this.kick(); }
});
}
layout() {
const W = this.root.clientWidth;
if (!W) return;
const H = W / ASPECT;
const r = H / 2;
this.W = W;
this.H = H;
this.r = r;
this.orbR = r * ORB;
this.padX = r * 0.6; // room for a bulging end cap
this.padY = r * (BULGE - 1) + r * 0.25; // room for the top/bottom bulge
this.mid = this.padY + r;
const first = W * EDGE;
const step = (W - first * 2) / (this.tabs.length - 1);
this.centers = this.tabs.map((_, i) => first + step * i);
this.step = step;
this.root.style.height = `${H + this.padY * 2}px`;
this.svg.setAttribute('width', W + this.padX * 2);
this.svg.setAttribute('height', H + this.padY * 2);
this.svg.setAttribute('viewBox', `${-this.padX} 0 ${W + this.padX * 2} ${H + this.padY * 2}`);
this.svg.style.left = `${-this.padX}px`;
this.root.style.setProperty('--en-orb', `${this.orbR * 2}px`);
this.root.style.setProperty('--en-icon', `${H * 0.4}px`);
this.buttons.forEach((b, i) => {
b.style.left = `${this.centers[i] - step / 2}px`;
b.style.width = `${step}px`;
b.style.top = `${this.padY}px`;
b.style.height = `${H}px`;
});
if (!this.dragging) {
this.target = this.centers[this.active];
if (!this.running) this.x = this.target;
}
this.render();
}
// Half-height of the liquid outline at x, with the orb's bulge centred on cx.
profile(x, cx) {
const { W, r } = this;
const k = r * BLEND, R = r * BULGE;
let hp = -k;
if (x >= 0 && x <= W) {
if (x < r) hp = Math.sqrt(Math.max(0, r * r - (r - x) ** 2));
else if (x > W - r) hp = Math.sqrt(Math.max(0, r * r - (x - (W - r)) ** 2));
else hp = r;
}
const d = x - cx;
const hc = Math.abs(d) < R ? Math.sqrt(R * R - d * d) : -k;
return Math.max(0, smax(hp, hc, k));
}
shapePath(cx, inset = 0) {
const R = this.r * BULGE;
const xa = Math.min(0, cx - R), xb = Math.max(this.W, cx + R);
const xs = [];
// dense near the ends (vertical tangents), 1px elsewhere
for (let x = xa; x < xa + 4; x += 0.25) xs.push(x);
for (let x = xa + 4; x < xb - 4; x += 1) xs.push(x);
for (let x = xb - 4; x <= xb; x += 0.25) xs.push(x);
const pts = xs.map((x) => [x, Math.max(0, this.profile(x, cx) - inset)]).filter(([, h], i, a) => h > 0 || i === 0 || i === a.length - 1);
const top = pts.map(([x, h]) => `${x.toFixed(2)},${(this.mid - h).toFixed(2)}`);
const bottom = pts.reverse().map(([x, h]) => `${x.toFixed(2)},${(this.mid + h).toFixed(2)}`);
return `M${top.join(' L')} L${bottom.join(' L')} Z`;
}
nearest(x) {
return clamp(Math.round((x - this.centers[0]) / this.step), 0, this.tabs.length - 1);
}
select(i, { silent = false } = {}) {
const changed = i !== this.active;
this.active = i;
this.target = this.centers[i];
this.buttons.forEach((b, k) => {
b.setAttribute('aria-selected', k === i);
b.tabIndex = k === i ? 0 : -1;
});
if (reducedMotion.matches) {
this.x = this.target;
this.v = 0;
}
if (changed && !silent) this.onChange?.(this.tabs[i], i);
if (silent) this.onChange?.(this.tabs[i], i);
this.kick();
}
/* ── pointer: tap a tab, or grab the orb and drag ── */
localX(e) {
return e.clientX - this.root.getBoundingClientRect().left;
}
down(e) {
const px = this.localX(e);
this.pressX = px;
this.moved = false;
if (Math.abs(px - this.x) < this.orbR * 1.4) {
this.dragging = true;
this.root.setPointerCapture(e.pointerId);
this.root.classList.add('is-dragging');
}
}
move(e) {
const px = this.localX(e);
if (this.pressX !== undefined && Math.abs(px - this.pressX) > 4) this.moved = true;
if (this.dragging) {
this.target = clamp(px, this.centers[0], this.centers[this.centers.length - 1]);
const i = this.nearest(this.target);
if (i !== this.active) {
this.active = i;
this.buttons.forEach((b, k) => b.setAttribute('aria-selected', k === i));
this.onChange?.(this.tabs[i], i);
}
} else {
this.hover = px;
}
this.kick();
}
up(e) {
const px = this.localX(e);
if (this.dragging) {
this.dragging = false;
this.root.classList.remove('is-dragging');
this.select(this.nearest(this.target));
} else if (this.pressX !== undefined && !this.moved) {
this.select(this.nearest(px));
}
this.pressX = undefined;
}
/* ── animation ── */
kick() {
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;
const k = this.dragging ? DRAG_STIFFNESS : STIFFNESS;
const c = this.dragging ? DRAG_DAMPING : DAMPING;
// spring with a little overshoot; substeps keep it stable at low frame rates
for (let s = 0; s < 4; s++) {
const h = dt / 4;
this.v += (-k * (this.x - this.target) - c * this.v) * h;
this.x += this.v * h;
}
this.render();
const settled = Math.abs(this.x - this.target) < 0.05 && Math.abs(this.v) < 0.5;
if (settled && !this.dragging && this.hover === null) {
this.x = this.target;
this.v = 0;
this.render();
this.running = false;
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
render() {
if (!this.W) return;
const { r, mid, orbR, el } = this;
// magnetic lean towards a hovering pointer near the orb
let lean = 0;
if (this.hover !== null && !this.dragging) {
const d = this.hover - this.x;
if (Math.abs(d) < orbR * 2.2) lean = clamp(d * 0.12, -orbR * 0.18, orbR * 0.18);
}
const cx = this.x + lean;
const path = this.shapePath(cx);
el.body.setAttribute('d', path);
el.clip.setAttribute('d', path);
el.gloss.setAttribute('d', path);
el.rim.setAttribute('d', path);
el.hot.setAttribute('d', path);
el.inner.setAttribute('d', this.shapePath(cx, 3.6));
el.edge.setAttribute('d', this.shapePath(cx, 1.6));
// amber light on the bar around the orb, a dark socket under it, and an amber run in the rim
el.spillGrad.setAttribute('cx', cx);
el.spillGrad.setAttribute('cy', mid);
el.spillGrad.setAttribute('r', r * 2.6);
el.spill.setAttribute('cx', cx);
el.spill.setAttribute('cy', mid);
el.spill.setAttribute('r', r * 2.6);
el.socket.setAttribute('cx', cx);
el.socket.setAttribute('cy', mid + orbR * 0.06);
el.socket.setAttribute('r', orbR * 1.26);
el.socketRim.setAttribute('cx', cx);
el.socketRim.setAttribute('cy', mid);
el.socketRim.setAttribute('r', orbR * 1.27);
const x0 = -this.padX, span = this.W + this.padX * 2;
const at = (x) => clamp((x - x0) / span, 0, 1);
const stops = [0, at(cx - r * 2.4), at(cx - r * 0.9), at(cx + r * 0.9), at(cx + r * 2.4), 1];
stops.forEach((o, i) => {
el.rimStops[i].setAttribute('offset', o);
el.hotStops[i].setAttribute('offset', o);
});
for (const g of [el.rimGrad, el.hotGrad]) {
g.setAttribute('x1', x0);
g.setAttribute('x2', x0 + span);
}
// orb: stretches with speed, settles round
const speed = Math.abs(this.v);
const stretch = clamp(speed / 1500, 0, 0.2);
el.orb.style.transform = `translate(${cx}px, ${mid}px) translate(-50%, -50%)`;
el.ball.style.transform = `scale(${1 + stretch}, ${1 - stretch * 0.6})`;
// light trail behind the orb, and the warm glow it throws on the page below
const dir = Math.sign(this.v) || 1;
el.trail.style.transform = `translate(${cx - dir * Math.min(speed * 0.05, r * 1.6)}px, ${mid}px) translate(-50%, -50%) scaleX(${1 + Math.min(speed / 260, 2.4)})`;
el.trail.style.opacity = clamp(speed / 700, 0, 0.75);
// pool of light spilling from the bottom edge, a soft halo, and the hot flare on the bulge's crest
const crest = this.profile(cx, cx);
el.glow.style.transform = `translate(${cx}px, ${mid + crest * 0.95}px) translate(-50%, -50%)`;
el.halo.style.transform = `translate(${cx}px, ${mid + r * 0.2}px) translate(-50%, -50%)`;
el.flare.style.transform = `translate(${cx}px, ${mid - crest}px) translate(-50%, -50%)`;
// icons fade out as the orb covers them; the orb shows the icon it is closest to
this.buttons.forEach((b, i) => {
const d = Math.abs(cx - this.centers[i]);
const covered = clamp(1 - (d - orbR * 0.6) / (orbR * 0.9), 0, 1);
let mag = 0, scale = 1;
if (this.hover !== null && !this.dragging) {
const hd = this.hover - this.centers[i];
if (Math.abs(hd) < this.step * 0.5) {
mag = clamp(hd * 0.08, -3, 3);
scale = 1.1;
}
}
const icon = b.firstElementChild;
icon.style.opacity = 1 - covered;
icon.style.transform = `translateX(${mag}px) scale(${scale * (1 - covered * 0.3)})`;
});
const show = this.nearest(cx);
if (show !== this.shown || !el.glyph.innerHTML) {
this.shown = show;
el.glyph.innerHTML = this.icon(this.tabs[show].icon);
el.glyph.classList.remove('is-pop');
void el.glyph.offsetWidth;
el.glyph.classList.add('is-pop');
}
}
}
EmberNav.icons = ICONS;
window.EmberNav = EmberNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ember 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=Manrope:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="ember-nav.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
font-family: "Manrope", system-ui, sans-serif;
color: #4a3f63;
/* soft peach / lavender / blush silk */
background:
radial-gradient(ellipse 60% 45% at 18% 22%, rgba(214, 196, 245, 0.95), transparent 70%),
radial-gradient(ellipse 55% 40% at 85% 30%, rgba(250, 196, 170, 0.9), transparent 70%),
radial-gradient(ellipse 70% 45% at 30% 85%, rgba(246, 186, 160, 0.85), transparent 70%),
radial-gradient(ellipse 60% 50% at 88% 88%, rgba(196, 180, 238, 0.9), transparent 70%),
linear-gradient(160deg, #efe3f3, #f7ddd2 50%, #e6dcf3);
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.label {
align-self: center;
width: 100%;
max-width: 380px;
margin: 0 0 4px;
padding-left: 6px;
color: #5a4d78;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.22em;
}
.swap { animation: swap 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); }
@keyframes swap {
from { opacity: 0; transform: translateY(6px); filter: blur(4px); }
}
.hint {
margin: 36px 0 0;
color: #7d6f98;
font-size: 13px;
}
@media (prefers-reduced-motion: reduce) {
.swap { animation: none; }
}
</style>
</head>
<body>
<main class="stage">
<p class="label" id="label">HOME</p>
<nav id="nav"></nav>
<p class="hint">Tap an icon, or drag the glowing orb.</p>
</main>
<script src="ember-nav.js"></script>
<script>
const label = document.getElementById('label');
new EmberNav(document.getElementById('nav'), {
active: 2,
tabs: [
{ label: 'Dashboard', icon: 'grid' },
{ label: 'Favorites', icon: 'heart' },
{ label: 'Home', icon: 'home' },
{ label: 'Notifications', icon: 'bell' },
{ label: 'Profile', icon: 'user' },
],
onChange(tab) {
const text = tab.label.toUpperCase();
if (label.textContent === text) return;
label.textContent = text;
label.classList.remove('swap');
void label.offsetWidth;
label.classList.add('swap');
},
});
</script>
</body>
</html>