№ 004
Bauhaus Duo
An ivory capsule tab bar where a blue tile leads and a mustard circle trails behind it.
- Version
- 1.0.0
- Download
- 5 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, geometric, spring
Usage
Copy bauhaus-nav.css and bauhaus-nav.js (and assets/, if there is one) into your project, then:
new BauhausNav(element, { tabs: [{ label, icon, href? }], active: 0, onChange(tab, index) {} })
The active link carries aria-current="page". `icon` is a key of BauhausNav.icons or raw SVG.Source
/* Bauhaus Duo — an ivory capsule with a blue tile and a trailing mustard circle. */
.bn {
--h: clamp(76px, 14.5vw, 100px);
--u: calc(var(--h) / 100);
--ink: #403d39;
--blue: #1757b8;
--mustard: #ffc13d;
position: relative;
width: 100%;
max-width: 680px;
height: var(--h);
margin: 0 auto;
user-select: none;
-webkit-tap-highlight-color: transparent;
filter:
drop-shadow(0 calc(var(--u) * 1) calc(var(--u) * 1.5) rgba(70, 50, 25, 0.14))
drop-shadow(0 calc(var(--u) * 6) calc(var(--u) * 10) rgba(70, 50, 25, 0.12))
drop-shadow(0 calc(var(--u) * 20) calc(var(--u) * 32) rgba(70, 50, 25, 0.16));
}
.bn-clip {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: 999px;
background: linear-gradient(#fffefb, #fffcf7 45%, #f8f2e8);
box-shadow:
inset 0 calc(var(--u) * 1.5) 0 rgba(255, 255, 255, 1),
inset 0 calc(var(--u) * -3) calc(var(--u) * 6) rgba(120, 90, 50, 0.1),
inset 0 0 0 1px rgba(120, 90, 50, 0.06);
}
.bn-yellow,
.bn-blue {
position: absolute;
top: calc(var(--u) * 3);
left: 0;
height: calc(100% - var(--u) * 6);
border-radius: 999px;
will-change: transform;
transition: transform 0.45s cubic-bezier(0.34, 1.28, 0.5, 1);
}
.bn-blue {
width: calc(var(--h) * 1.16);
background: linear-gradient(#1d62c6, #1757b8 55%, #12479a);
box-shadow:
inset 0 calc(var(--u) * 1.5) 0 rgba(255, 255, 255, 0.22),
inset 0 calc(var(--u) * -4) calc(var(--u) * 6) rgba(5, 25, 70, 0.28),
calc(var(--u) * 4) calc(var(--u) * 2) calc(var(--u) * 10) rgba(60, 40, 0, 0.28),
0 calc(var(--u) * 6) calc(var(--u) * 12) rgba(10, 30, 80, 0.3);
z-index: 2;
}
.bn-yellow {
width: calc(var(--h) - var(--u) * 6);
background: linear-gradient(#ffcb58, #ffc13d 55%, #f0ac26);
box-shadow:
inset 0 calc(var(--u) * 1.5) 0 rgba(255, 255, 255, 0.35),
inset 0 calc(var(--u) * -4) calc(var(--u) * 6) rgba(150, 90, 0, 0.22),
0 calc(var(--u) * 4) calc(var(--u) * 9) rgba(120, 80, 0, 0.22);
transition-delay: 0.075s;
z-index: 1;
}
.bn-sep {
position: absolute;
top: 22%;
height: 56%;
width: 1px;
background: rgba(64, 61, 57, 0.14);
transition: opacity 0.2s;
z-index: 0;
}
.bn-sep.off { opacity: 0; }
.bn-tabs {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
z-index: 3;
}
.bn-tab {
position: relative;
display: block;
min-height: 44px;
color: var(--ink);
text-decoration: none;
border-radius: 999px;
transition: color 0.2s;
outline: none;
}
.bn-tab[aria-current="page"] { color: #fff; }
.bn-tab[aria-current="page"] .bn-icon,
.bn-tab[aria-current="page"] .bn-name { filter: drop-shadow(0 calc(var(--u) * 1) calc(var(--u) * 1.5) rgba(5, 25, 70, 0.35)); }
.bn-tab:focus-visible::after {
content: "";
position: absolute;
inset: calc(var(--u) * 6);
border-radius: 999px;
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.bn-icon,
.bn-name,
.bn-dot {
position: absolute;
left: 50%;
}
.bn-icon {
top: calc(var(--u) * 39);
width: calc(var(--u) * 28);
height: calc(var(--u) * 28);
translate: -50% -50%;
transition: top 0.2s;
}
.bn-icon svg { width: 100%; height: 100%; display: block; }
.bn-icon .fillable { fill: currentColor; fill-opacity: 0; transition: fill-opacity 0.2s; }
.bn-tab[aria-current="page"] .fillable { fill-opacity: 1; }
.bn-name {
top: calc(var(--u) * 66);
translate: -50% -50%;
font-size: calc(var(--u) * 14);
font-weight: 500;
letter-spacing: 0.005em;
white-space: nowrap;
}
.bn-tab[aria-current="page"] .bn-name { top: calc(var(--u) * 63); }
.bn-dot {
top: calc(var(--u) * 81);
width: calc(var(--u) * 7);
height: calc(var(--u) * 7);
translate: -50% -50%;
border-radius: 50%;
background: #fff;
opacity: 0;
scale: 0.4;
transition: opacity 0.2s, scale 0.3s cubic-bezier(0.34, 1.56, 0.5, 1);
}
.bn-tab[aria-current="page"] .bn-dot { opacity: 1; scale: 1; }
@media (max-width: 480px) {
.bn { width: calc(100% - 24px); }
.bn-name { font-size: calc(var(--u) * 15); }
}
@media (prefers-reduced-motion: reduce) {
.bn-yellow, .bn-blue, .bn-dot, .bn-tab, .bn-sep { transition: none; }
} /* Bauhaus Duo — usage:
* new BauhausNav(element, { tabs: [{ label, icon, href? }], active: 0, onChange(tab, index) {} })
* The active link carries aria-current="page". `icon` is a key of BauhausNav.icons or raw SVG.
*/
(function () {
const svg = (body) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
const ICONS = {
home: svg('<path class="fillable" d="M4.5 10.5 12 4l7.5 6.5V19a1 1 0 0 1-1 1h-3.7v-5.2H9.2V20H5.5a1 1 0 0 1-1-1Z"/><path d="M3 11.2 12 3.5l9 7.7"/>'),
user: svg('<circle cx="12" cy="7.5" r="3.8"/><path d="M4.5 20.5c.6-4.2 3.7-6.6 7.5-6.6s6.9 2.4 7.5 6.6"/>'),
chat: svg('<path d="M12 3.5a8.5 8.5 0 1 1-4.3 15.8L3.6 20.4l1.2-3.9A8.5 8.5 0 0 1 12 3.5Z"/>'),
camera: svg('<path d="M4 7.5h3.2l1.4-2.2a1 1 0 0 1 .85-.5h5.1a1 1 0 0 1 .85.5l1.4 2.2H20a1.5 1.5 0 0 1 1.5 1.5v9A1.5 1.5 0 0 1 20 19.5H4A1.5 1.5 0 0 1 2.5 18V9A1.5 1.5 0 0 1 4 7.5Z"/><circle cx="12" cy="13" r="3.6"/>'),
sliders: svg('<path d="M3 7h6.2M14.8 7H21M3 17h2.2M10.8 17H21"/><circle cx="12" cy="7" r="2.6"/><circle cx="8" cy="17" r="2.6"/>'),
};
class BauhausNav {
constructor(root, { tabs, active = 0, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = active;
this.onChange = onChange;
this.reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
this.build();
this.place(false);
new ResizeObserver(() => this.place(false)).observe(root);
}
build() {
const n = this.tabs.length;
this.root.classList.add('bn');
this.root.innerHTML = `
<div class="bn-clip">
<span class="bn-yellow"></span>
<span class="bn-blue"></span>
${Array.from({ length: n - 1 }, () => '<span class="bn-sep"></span>').join('')}
</div>
<div class="bn-tabs"></div>`;
this.blue = this.root.querySelector('.bn-blue');
this.yellow = this.root.querySelector('.bn-yellow');
this.seps = [...this.root.querySelectorAll('.bn-sep')];
const list = this.root.querySelector('.bn-tabs');
this.links = this.tabs.map((tab, i) => {
const a = document.createElement('a');
a.className = 'bn-tab';
a.href = tab.href || `#${tab.label.toLowerCase()}`;
a.setAttribute('aria-label', tab.label);
a.innerHTML = `<span class="bn-icon">${ICONS[tab.icon] || tab.icon}</span><span class="bn-name">${tab.label}</span><span class="bn-dot"></span>`;
a.addEventListener('click', (e) => {
e.preventDefault();
this.select(i);
});
list.appendChild(a);
return a;
});
this.links.forEach((a, i) => (i === this.active ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current')));
}
place(animate) {
const W = this.root.clientWidth;
const H = this.root.clientHeight;
const n = this.tabs.length;
const slot = W / n;
const i = this.active;
const c = slot * (i + 0.5);
const bw = H * 1.16;
const yw = H - H * 0.06;
const side = i === n - 1 ? -1 : 1;
const yc = c + side * (bw / 2 + H * 0.25 - yw / 2);
if (!animate || this.reduced.matches) {
this.blue.style.transition = this.yellow.style.transition = 'none';
}
this.blue.style.transform = `translateX(${c - bw / 2}px)`;
this.yellow.style.transform = `translateX(${yc - yw / 2}px)`;
this.seps.forEach((s, k) => {
s.style.left = `${slot * (k + 1)}px`;
const near = Math.abs(slot * (k + 1) - c) < bw / 2 + 4 || Math.abs(slot * (k + 1) - yc) < yw / 2 + 4;
s.classList.toggle('off', near);
});
if (!animate || this.reduced.matches) {
void this.blue.offsetWidth;
this.blue.style.transition = this.yellow.style.transition = '';
}
}
select(i) {
if (i === this.active) return;
this.active = i;
this.links.forEach((a, j) => (j === i ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current')));
this.place(true);
if (!this.reduced.matches) {
this.blue.animate(
[{ scale: '1' }, { scale: '1.04', offset: 0.5 }, { scale: '1' }],
{ duration: 450, easing: 'ease-in-out' }
);
}
if (this.onChange) this.onChange(this.tabs[i], i);
}
}
BauhausNav.icons = ICONS;
window.BauhausNav = BauhausNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bauhaus Duo</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&display=swap" rel="stylesheet">
<link rel="stylesheet" href="bauhaus-nav.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
background: #faf7f0;
color: #403d39;
font-family: "Inter", "Helvetica Neue", system-ui, sans-serif;
}
</style>
</head>
<body>
<nav id="nav" aria-label="Main"></nav>
<script src="bauhaus-nav.js"></script>
<script>
new BauhausNav(document.getElementById('nav'), {
tabs: [
{ label: 'Home', icon: 'home' },
{ label: 'Profile', icon: 'user' },
{ label: 'Chat', icon: 'chat' },
{ label: 'Camera', icon: 'camera' },
{ label: 'Settings', icon: 'sliders' },
],
active: 0,
});
</script>
</body>
</html>