№ 009
Mochi Nav
A kawaii tab bar of jelly buttons, with a kitten that hops over to the active one.
- Version
- 1.0.0
- Download
- 780 KB · 10 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, kawaii, jelly
Usage
Copy mochi-nav.css and mochi-nav.js (and assets/, if there is one) into your project, then:
new MochiNav(element, {
tabs: [{ label, img }], // img: URL of a glossy round button (transparent PNG)
active: 2,
kitten: 'assets/kitten.png',
sparkles: 'assets/sparkles.png',
onChange(tab, index) {},
})
A kawaii tab bar: the active jelly button grows and rises, the milky shell dips under it,
and a kitten hops over to rest her paws on top of it.Source
/* Mochi nav — kawaii tab bar: milky pill, jelly buttons, a kitten who hops to the active tab. */
.mn {
position: relative;
width: 100%;
max-width: 420px;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mn-shell {
position: absolute;
left: 0;
top: 0;
overflow: visible;
pointer-events: none;
filter:
drop-shadow(0 18px 26px rgba(150, 118, 214, 0.28))
drop-shadow(0 4px 8px rgba(150, 118, 214, 0.18));
}
.mn-tabs {
position: absolute;
inset: 0;
}
.mn-tab {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: calc(var(--mn-d) * 0.12);
padding: 0;
border: 0;
background: none;
cursor: pointer;
font: inherit;
}
.mn-tab:focus-visible { outline: none; }
.mn-tab:focus-visible .mn-jelly { box-shadow: 0 0 0 3px rgba(255, 111, 115, 0.45); border-radius: 50%; }
.mn-jelly {
display: block;
width: var(--mn-d);
height: var(--mn-d);
transform-origin: 50% 70%;
transition: transform 0.55s cubic-bezier(0.34, 1.56, 0.64, 1), filter 0.4s;
filter: drop-shadow(0 4px 6px rgba(140, 110, 200, 0.22));
}
.mn-jelly img {
width: 100%;
height: 100%;
display: block;
pointer-events: none;
}
.mn-tab:hover .mn-jelly { transform: scale(1.07) translateY(-2px); }
.mn-tab:active .mn-jelly { transform: scale(0.93); }
.mn-tab.is-active .mn-jelly {
transform: translateY(var(--mn-lift)) scale(var(--mn-grow));
filter: drop-shadow(0 8px 12px rgba(255, 110, 120, 0.35));
}
.mn-label {
color: #a493cc;
font-size: calc(var(--mn-d) * 0.27);
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1;
transition: color 0.3s, transform 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.mn-tab.is-active .mn-label {
color: #ff6b70;
transform: translateY(calc(var(--mn-d) * 0.06));
}
.mn-kitten {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 6px 8px rgba(150, 110, 200, 0.22));
}
.mn-cat {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
/* by her right ear */
.mn-sparkles {
position: absolute;
left: 92%;
top: -8%;
width: 30%;
transform-origin: 30% 70%;
will-change: transform, opacity;
}
@media (prefers-reduced-motion: reduce) {
.mn-jelly, .mn-label { transition: none; }
} /* Mochi nav — usage:
* new MochiNav(element, {
* tabs: [{ label, img }], // img: URL of a glossy round button (transparent PNG)
* active: 2,
* kitten: 'assets/kitten.png',
* sparkles: 'assets/sparkles.png',
* onChange(tab, index) {},
* })
*
* A kawaii tab bar: the active jelly button grows and rises, the milky shell dips under it,
* and a kitten hops over to rest her paws on top of it.
*/
(function () {
const BTN = 0.13; // inactive button diameter (× bar width)
const GROW = 1.18; // active button scale
const LIFT = 0.04; // how far the active button rises (× inactive diameter) — stays in line
const EDGE = 0.11; // first/last button centre (× bar width)
const INSET = 0.05; // bar inset from the component's edges, so the kitten fits at the end tabs
const DIP = 0.12; // depth of the shell's dip under the active button (× diameter)
const KITTEN_W = 1.3; // kitten width (× active diameter); her paws rest on the button's top edge
const PAW_X = 0.55; // midpoint between her paws, as a share of the kitten image width
const KITTEN_AR = 490 / 640;
const NOTCH_STIFF = 170;
const NOTCH_DAMP = 20;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
let uid = 0;
class MochiNav {
constructor(root, { tabs, active = 0, kitten, sparkles, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = active;
this.onChange = onChange;
this.kittenSrc = kitten;
this.sparklesSrc = sparkles;
this.notch = 0; // x of the shell's dip (springs to the active button)
this.notchV = 0;
this.hop = null; // { from, to, start, dur } while the kitten is jumping
this.landedAt = -Infinity;
this.running = false;
this.build();
this.layout();
this.notch = this.centers[active];
this.paint(performance.now());
new ResizeObserver(() => this.layout()).observe(root);
this.loop(); // idle: gentle breathing and twinkling
}
build() {
const id = ++uid;
this.root.classList.add('mn');
this.root.innerHTML = `
<svg class="mn-shell" aria-hidden="true">
<defs>
<linearGradient id="mn-fill-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="0.55" stop-color="#fbf8ff"/>
<stop offset="1" stop-color="#efe8fb"/>
</linearGradient>
<linearGradient id="mn-gloss-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#fff" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#fff" stop-opacity="0"/>
</linearGradient>
<linearGradient id="mn-edge-${id}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ffffff"/>
<stop offset="1" stop-color="#d9cdf3"/>
</linearGradient>
</defs>
<path class="mn-body" fill="url(#mn-fill-${id})"/>
<path class="mn-gloss" fill="url(#mn-gloss-${id})"/>
<path class="mn-edge" fill="none" stroke="url(#mn-edge-${id})" stroke-width="1.5"/>
</svg>
<div class="mn-tabs" role="tablist" aria-label="Main"></div>
<div class="mn-kitten" aria-hidden="true">
<img class="mn-cat" alt="" draggable="false">
<img class="mn-sparkles" alt="" draggable="false">
</div>`;
const q = (s) => this.root.querySelector(s);
this.shell = q('.mn-shell');
this.body = q('.mn-body');
this.gloss = q('.mn-gloss');
this.edge = q('.mn-edge');
this.kitten = q('.mn-kitten');
this.cat = q('.mn-cat');
this.spark = q('.mn-sparkles');
if (this.kittenSrc) this.cat.src = this.kittenSrc;
if (this.sparklesSrc) this.spark.src = this.sparklesSrc;
const list = q('.mn-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'mn-tab';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', tab.label);
b.innerHTML = `<span class="mn-jelly"><img src="${tab.img}" alt="" draggable="false"></span><span class="mn-label">${tab.label}</span>`;
b.addEventListener('click', () => this.select(i));
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();
});
}
layout() {
const full = this.root.clientWidth;
if (!full) return;
const W = full * (1 - INSET * 2); // the bar's own width
this.x0 = full * INSET; // where the bar starts
this.full = full;
const d = W * BTN;
const D = d * GROW;
this.W = W;
this.d = d;
this.D = D;
this.barH = d * 1.95;
this.top = D * 0.95; // room above the bar for the kitten
this.btnY = this.top + d * 0.74; // centre line of the resting buttons
const first = this.x0 + W * EDGE;
this.step = (W - W * EDGE * 2) / (this.tabs.length - 1);
this.centers = this.tabs.map((_, i) => first + this.step * i);
const H = this.top + this.barH + d * 0.5;
this.root.style.height = `${H}px`;
this.root.style.setProperty('--mn-d', `${d}px`);
this.root.style.setProperty('--mn-grow', GROW);
this.root.style.setProperty('--mn-lift', `${-d * LIFT}px`);
this.shell.setAttribute('width', full);
this.shell.setAttribute('height', H);
this.shell.setAttribute('viewBox', `0 0 ${full} ${H}`);
this.buttons.forEach((b, i) => {
b.style.left = `${this.centers[i] - this.step / 2}px`;
b.style.width = `${this.step}px`;
b.style.top = `${this.btnY - d / 2}px`;
b.classList.toggle('is-active', i === this.active);
b.setAttribute('aria-selected', i === this.active);
b.tabIndex = i === this.active ? 0 : -1;
});
const kw = D * KITTEN_W;
this.kitten.style.width = `${kw}px`;
this.kitten.style.height = `${kw * KITTEN_AR}px`;
if (!this.hop) this.notch = this.centers[this.active];
this.paint(performance.now());
}
// Milky pill with a soft dip in the top edge under the active button.
shellPath(cx) {
const { W, x0, D, top, barH } = this;
const r = barH / 2, y0 = top, y1 = top + barH;
const L = x0 + r, Rr = x0 + W - r;
const depth = D * DIP, half = D * 0.95;
const a = Math.max(L, cx - half), b = Math.min(Rr, cx + half);
return [
`M${L},${y0} L${a},${y0}`,
`C${cx - half * 0.45},${y0} ${cx - half * 0.55},${y0 + depth} ${cx},${y0 + depth}`,
`C${cx + half * 0.55},${y0 + depth} ${cx + half * 0.45},${y0} ${b},${y0}`,
`L${Rr},${y0} A${r},${r} 0 0 1 ${Rr},${y1}`,
`L${L},${y1} A${r},${r} 0 0 1 ${L},${y0} Z`,
].join(' ');
}
select(i) {
if (i === this.active) {
this.landedAt = performance.now(); // a little happy bounce when re-tapped
return;
}
const from = this.active;
this.active = i;
this.buttons.forEach((b, k) => {
b.classList.toggle('is-active', k === i);
b.setAttribute('aria-selected', k === i);
b.tabIndex = k === i ? 0 : -1;
});
if (reducedMotion.matches) {
this.hop = null;
this.notch = this.centers[i];
this.landedAt = -Infinity;
} else {
const now = performance.now();
const fromX = this.hop ? this.kittenX(now) : this.centers[from];
this.hop = { fromX, toX: this.centers[i], start: now, dur: 520 + 70 * Math.abs(i - from) };
}
this.onChange?.(this.tabs[i], i);
}
kittenX(now) {
if (!this.hop) return this.centers[this.active];
const t = clamp((now - this.hop.start) / this.hop.dur, 0, 1);
return this.hop.fromX + (this.hop.toX - this.hop.fromX) * easeInOut(t);
}
loop() {
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;
// the shell's dip follows the kitten on a soft spring
const target = this.kittenX(now);
for (let s = 0; s < 3; s++) {
const h = dt / 3;
this.notchV += (-NOTCH_STIFF * (this.notch - target) - NOTCH_DAMP * this.notchV) * h;
this.notch += this.notchV * h;
}
this.paint(now);
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
paint(now) {
if (!this.W) return;
const { D, d } = this;
const path = this.shellPath(this.notch);
this.body.setAttribute('d', path);
this.gloss.setAttribute('d', path);
this.edge.setAttribute('d', path);
// kitten: hop along an arc, lean into the jump, squash on take-off and landing
let x = this.kittenX(now), y = 0, sx = 1, sy = 1, rot = 0;
if (this.hop) {
const t = clamp((now - this.hop.start) / this.hop.dur, 0, 1);
const dir = Math.sign(this.hop.toX - this.hop.fromX);
const dist = Math.abs(this.hop.toX - this.hop.fromX) / this.step;
y = -Math.sin(Math.PI * t) * D * (0.2 + 0.03 * dist); // a small, gentle hop
rot = Math.sin(Math.PI * t) * 5 * dir;
if (t < 0.14) { const k = t / 0.14; sx = 1 + 0.1 * Math.sin(Math.PI * k); sy = 1 - 0.12 * Math.sin(Math.PI * k); }
else { sx = 1 - 0.04 * Math.sin(Math.PI * t); sy = 1 + 0.06 * Math.sin(Math.PI * t); }
if (t >= 1) {
this.hop = null;
this.landedAt = now;
}
}
// landing squash that wobbles back, then idle breathing
const since = (now - this.landedAt) / 1000;
if (since >= 0 && since < 0.7) {
const w = Math.exp(-since * 7) * Math.cos(since * 26);
sx *= 1 + 0.12 * w;
sy *= 1 - 0.14 * w;
}
const breathe = reducedMotion.matches ? 0 : Math.sin(now / 520) * 0.015;
sy *= 1 + breathe;
// paws rest on the top edge of the raised active button
const btnTop = this.btnY - d * LIFT - D / 2;
const kw = D * KITTEN_W, kh = kw * KITTEN_AR;
const left = x - kw * PAW_X;
const topY = btnTop + D * 0.14 - kh + y;
this.kitten.style.transform = `translate(${left}px, ${topY}px) rotate(${rot}deg) scale(${sx}, ${sy})`;
this.kitten.style.transformOrigin = `${PAW_X * 100}% 100%`;
// sparkles twinkle by her right ear, and burst a little on each landing
const burst = since >= 0 && since < 0.6 ? Math.sin((since / 0.6) * Math.PI) : 0;
const tw = reducedMotion.matches ? 0 : (Math.sin(now / 340) + 1) / 2;
this.spark.style.opacity = (this.hop ? 0.25 : 0.7 + 0.3 * tw).toFixed(3);
this.spark.style.transform = `scale(${(0.92 + 0.08 * tw + 0.35 * burst).toFixed(3)}) rotate(${(burst * 12).toFixed(2)}deg)`;
}
}
window.MochiNav = MochiNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mochi 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=Fredoka:wght@500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="mochi-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: "Fredoka", system-ui, sans-serif;
/* dreamy pale lavender with soft purple ambient light */
background:
radial-gradient(ellipse 70% 45% at 50% 60%, rgba(214, 196, 250, 0.75), transparent 70%),
radial-gradient(ellipse 60% 40% at 15% 15%, rgba(255, 255, 255, 0.9), transparent 70%),
radial-gradient(ellipse 50% 40% at 90% 90%, rgba(206, 186, 246, 0.8), transparent 70%),
linear-gradient(170deg, #f6f1ff, #ece3fc 55%, #e4d8fa);
}
#nav { width: 100%; }
.hint {
margin: 40px 0 0;
color: #a493cc;
font-size: 14px;
font-weight: 500;
text-align: center;
}
</style>
</head>
<body>
<main style="width: 100%; max-width: 420px">
<nav id="nav"></nav>
<p class="hint">Tap a button and watch the kitten hop over.</p>
</main>
<script src="mochi-nav.js"></script>
<script>
new MochiNav(document.getElementById('nav'), {
active: 2,
kitten: 'assets/kitten.png',
sparkles: 'assets/sparkles.png',
tabs: [
{ label: 'Home', img: 'assets/home.png' },
{ label: 'Discover', img: 'assets/discover.png' },
{ label: 'Friends', img: 'assets/friends.png' },
{ label: 'Inbox', img: 'assets/inbox.png' },
{ label: 'Me', img: 'assets/me.png' },
],
});
</script>
</body>
</html> All 10 files in the zip
-
assets/discover.png -
assets/friends.png -
assets/home.png -
assets/inbox.png -
assets/kitten.png -
assets/me.png -
assets/sparkles.png -
index.html -
mochi-nav.css -
mochi-nav.js