№ 016
Dimple Nav
A soft ivory clay tab bar: press a tab and the surface sinks into a crater around a lit graphite switch, while the old one fills back in.
- Version
- 1.0.0
- Download
- 13 KB · 3 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- tab bar, tactile, webgl
Usage
Copy dimple-nav.css and dimple-nav.js (and assets/, if there is one) into your project, then:
new DimpleNav(element, { tabs: [{ label, icon }], active: 2, sound: true, onChange(tab, index) {} })
`icon` is a key of DimpleNav.icons or a raw SVG string.
A tactile tab bar made of soft ivory clay. Pressing a tab punches a crater into the surface where
it is — a circular dent spreads out, sinks into a bowl and a graphite switch with a lime light
settles inside — while the old crater fills itself back in. Nothing travels: the surface morphs.
The clay is a height field lit in a WebGL fragment shader (real normals, a cast shadow along the
light, occlusion in the bowl and a chamfered slab with a visible front wall), so the shading stays right at every moment of the deformation.
Icons and switches are plain DOM on top. Without WebGL the bar falls back to a flat CSS pill.Source
/* Dimple nav — ivory clay tab bar; pressing a tab punches a crater with a lit graphite switch in it. */
.dn {
position: relative;
width: 100%;
max-width: 640px;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.dn-bar {
position: relative;
width: 100%;
aspect-ratio: 3.6;
}
/* the slab's footprint: carries the ambient shadow, and is the whole bar when WebGL is missing */
.dn-shadow {
position: absolute;
inset: 0;
border-radius: 999px;
background: #e6dfd1;
box-shadow:
0 1px 1px rgba(70, 52, 30, 0.35),
0 calc(var(--dn-h) * 0.03) calc(var(--dn-h) * 0.04) rgba(70, 52, 30, 0.3),
0 calc(var(--dn-h) * 0.1) calc(var(--dn-h) * 0.14) calc(var(--dn-h) * -0.02) rgba(70, 52, 30, 0.38),
0 calc(var(--dn-h) * 0.24) calc(var(--dn-h) * 0.32) calc(var(--dn-h) * -0.06) rgba(70, 52, 30, 0.3),
0 calc(var(--dn-h) * 0.45) calc(var(--dn-h) * 0.6) calc(var(--dn-h) * -0.14) rgba(70, 52, 30, 0.18);
}
.dn-surface {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.dn-flat .dn-surface { display: none; }
.dn-flat .dn-shadow {
background: linear-gradient(180deg, #f6f1e7, #e9e1d2 70%, #d8cdb9);
}
.dn-tabs {
position: absolute;
inset: 0;
}
.dn-tab {
position: absolute;
top: var(--dn-face, 46%);
width: calc(var(--dn-h) * 0.62);
height: calc(var(--dn-h) * 0.62);
transform: translate(-50%, -50%);
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: #1c1a17;
cursor: pointer;
touch-action: manipulation;
}
.dn-tab.is-active { cursor: default; }
.dn-tab:focus-visible { outline: none; }
.dn-tab:focus-visible::after {
content: "";
position: absolute;
inset: calc(var(--dn-h) * -0.02);
border-radius: 50%;
box-shadow: 0 0 0 2px rgba(38, 35, 32, 0.55);
}
.dn-icon {
position: absolute;
inset: 0;
display: grid;
place-items: center;
will-change: transform, opacity;
}
.dn-icon svg {
width: calc(var(--dn-h) * 0.245);
height: calc(var(--dn-h) * 0.245);
overflow: visible;
/* printed into the clay: a hairline of light catches the lower edge of each stroke */
filter: drop-shadow(0 1px 0 rgba(255, 252, 244, 0.7)) drop-shadow(0 -0.5px 0 rgba(90, 70, 40, 0.25));
}
.dn-tab:not(.is-active):hover .dn-icon svg { transform: translateY(-1px); }
.dn-icon svg { transition: transform 0.2s; }
/* the graphite switch: a matte puck with a bevelled outer ring, and a neon lens in its face */
.dn-switch {
--dn-lit: 0;
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--dn-h) * 0.5);
height: calc(var(--dn-h) * 0.5);
border-radius: 50%;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
background:
/* lime light spilling over the face around the lens */
radial-gradient(circle, rgba(196, 245, 52, calc(0.1 * var(--dn-lit))) 0 22%, rgba(196, 245, 52, calc(0.03 * var(--dn-lit))) 32%, transparent 44%),
/* soft top-light falloff */
linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 45%, rgba(0, 0, 0, 0.18)),
/* face, the groove, the bevelled ring and its outer edge */
radial-gradient(circle, #333331 0 63%, #1b1b1a 67%, #2a2a28 70%, #575753 76%, #3a3a36 84%, #232321 95%, #1a1a19 100%);
box-shadow:
inset 0 calc(var(--dn-h) * 0.006) 0 rgba(255, 255, 255, 0.14),
inset 0 calc(var(--dn-h) * -0.01) calc(var(--dn-h) * 0.014) rgba(0, 0, 0, 0.5),
/* the crevice where it sits in the clay */
0 0 0 1px rgba(40, 30, 18, 0.35),
0 calc(var(--dn-h) * 0.008) calc(var(--dn-h) * 0.012) rgba(26, 18, 8, 0.7),
0 calc(var(--dn-h) * 0.03) calc(var(--dn-h) * 0.05) rgba(50, 36, 18, 0.45);
}
/* matte grain on the puck */
.dn-switch::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
opacity: 0.3;
mix-blend-mode: overlay;
-webkit-mask: radial-gradient(circle, #000 0 64%, transparent 66%);
mask: radial-gradient(circle, #000 0 64%, transparent 66%);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
}
.dn-led {
position: absolute;
left: 50%;
top: 50%;
width: calc(var(--dn-h) * 0.19);
height: calc(var(--dn-h) * 0.19);
border-radius: 50%;
transform: translate(-50%, -50%);
background: radial-gradient(circle at 50% 46%, #e2ff74 0%, #cdf93a 40%, #c3f22e 80%, #b2e223 100%);
filter: brightness(calc(0.42 + var(--dn-lit) * 0.58)) saturate(calc(0.35 + var(--dn-lit) * 0.65));
box-shadow:
inset 0 0 0 1px rgba(150, 190, 20, 0.5),
inset 0 -1px 2px rgba(80, 110, 0, 0.35),
0 0 calc(var(--dn-h) * 0.012 * var(--dn-lit)) rgba(214, 252, 70, calc(0.55 * var(--dn-lit))),
0 0 calc(var(--dn-h) * 0.06 * var(--dn-lit)) rgba(196, 245, 50, calc(0.18 * var(--dn-lit)));
animation: dn-breathe 3.2s ease-in-out infinite;
}
@keyframes dn-breathe {
50% { scale: 0.97; }
}
/* lime light spilling onto the surface below the bar */
.dn-glows {
position: absolute;
inset: 0;
pointer-events: none;
}
.dn-glow {
position: absolute;
top: calc(var(--dn-h) * 1.24);
width: calc(var(--dn-h) * 0.85);
height: calc(var(--dn-h) * 0.6);
transform: translate(-50%, -50%);
border-radius: 50%;
opacity: 0;
background: radial-gradient(closest-side, rgba(220, 255, 80, 0.85), rgba(196, 240, 50, 0.42) 40%, rgba(180, 225, 40, 0.12) 70%, transparent);
filter: blur(calc(var(--dn-h) * 0.04));
}
@media (prefers-reduced-motion: reduce) {
.dn-led { animation: none; }
} /* Dimple nav — usage:
* new DimpleNav(element, { tabs: [{ label, icon }], active: 2, sound: true, onChange(tab, index) {} })
* `icon` is a key of DimpleNav.icons or a raw SVG string.
*
* A tactile tab bar made of soft ivory clay. Pressing a tab punches a crater into the surface where
* it is — a circular dent spreads out, sinks into a bowl and a graphite switch with a lime light
* settles inside — while the old crater fills itself back in. Nothing travels: the surface morphs.
*
* The clay is a height field lit in a WebGL fragment shader (real normals, a cast shadow along the
* light, occlusion in the bowl and a chamfered slab with a visible front wall), so the shading stays right at every moment of the deformation.
* Icons and switches are plain DOM on top. Without WebGL the bar falls back to a flat CSS pill.
*/
(function () {
const ASPECT = 3.6; // bar width / height (the whole slab, side wall included)
const SIDE = 0.075; // visible thickness of the slab's front wall (× bar height)
const CHAMFER = 0.09; // width of the angled edge around the top face (× bar height)
const EDGE = 0.13; // first/last slot centre, as a share of the width
const BOWL = 0.38; // crater radius at its rim (× bar height)
const DEPTH = 0.3; // crater depth (× bar height)
const RIM = 0.022; // height of the raised lip around the crater (× bar height)
const PRESS = { s: 0.42, d: 0.3 }; // the shallow dent while a finger is down on a tab
const DEPTH_DELAY = 90; // ms the dent spreads before it really sinks
const FILL_DELAY = 160; // ms an old crater fills in before its outline closes
const LIGHT_DELAY = 470; // ms before the lime light comes on
const SPRINGS = {
s: [260, 22], // crater radius: quick, a touch of overshoot
d: [170, 13], // crater depth: softer, settles with a clay-like bounce
g: [110, 17], // lime glow
};
const svg = (body, stroke = 1.6) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`;
const ICONS = {
home: svg('<path fill="currentColor" d="M11.2 3.4a1.2 1.2 0 0 1 1.6 0l8.1 7.2c.5.4.2 1.2-.5 1.2h-1.5v7.6a1.1 1.1 0 0 1-1.1 1.1h-3.4v-5.2a1.1 1.1 0 0 0-1.1-1.1h-2.6a1.1 1.1 0 0 0-1.1 1.1v5.2H6.2a1.1 1.1 0 0 1-1.1-1.1v-7.6H3.6c-.7 0-1-.8-.5-1.2z"/>', 1.2),
blocks: svg('<rect x="9.6" y="3" width="9.4" height="9" rx="2.2"/><rect x="3" y="12" width="9" height="9" rx="2.2"/><rect x="12" y="12" width="9" height="9" rx="2.2"/>'),
bell: svg('<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>'),
search: svg('<circle cx="10.6" cy="10.6" r="7.1"/><path d="m15.8 15.8 4.9 4.9"/>'),
user: svg('<circle cx="12" cy="7.6" r="3.9"/><path d="M5.2 19.4v-.6a4.4 4.4 0 0 1 4.4-4.4h4.8a4.4 4.4 0 0 1 4.4 4.4v.6a1.2 1.2 0 0 1-1.2 1.2H6.4a1.2 1.2 0 0 1-1.2-1.2z"/>'),
};
const VERT = `
attribute vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }
`;
// Coordinates are CSS px with y pointing down; height is in the same units.
// The slab is seen slightly from the front: a pill-shaped top face with a chamfered edge,
// and below it the slab's front wall, which is simply the same pill shifted down.
const FRAG = `
precision highp float;
uniform vec2 uSize;
uniform float uDpr;
uniform vec4 uCrater[5]; // centre x, centre y, radius scale, depth scale
uniform float uGlow[5];
const float SIDE = ${SIDE.toFixed(3)};
const float CHAMFER = ${CHAMFER.toFixed(3)};
const float BOWL = ${BOWL.toFixed(3)};
const float DEPTH = ${DEPTH.toFixed(3)};
const float RIM = ${RIM.toFixed(3)};
const vec3 LIGHT = vec3(-0.15, -0.62, 0.8);
const vec3 LIME = vec3(0.79, 0.97, 0.22);
float faceH() { return uSize.y * (1.0 - SIDE); }
float pill(vec2 p, float cy) {
float r = faceH() * 0.5;
vec2 q = p - vec2(uSize.x * 0.5, cy);
q.x = max(abs(q.x) - (uSize.x * 0.5 - r), 0.0);
return length(q) - r;
}
float topPill(vec2 p) { return pill(p, faceH() * 0.5); }
float basePill(vec2 p) { return pill(p, faceH() * 0.5 + uSize.y * SIDE); }
// the undeformed top: an angled chamfer (corners only slightly softened) up to a near-flat face
float slab(vec2 p, float inside) {
float c = uSize.y * CHAMFER;
float t = clamp(inside / c, 0.0, 1.0);
float ramp = mix(t, t * t * (3.0 - 2.0 * t), 0.28);
float v = (p.y / faceH() - 0.5) * 2.0;
return c * ramp + uSize.y * 0.014 * t * (1.0 - v * v);
}
// a funnel: steep near the floor, easing out into a softly raised lip
float crater(vec2 p, vec4 c) {
float r = max(c.z, 0.04) * uSize.y * BOWL;
float x = length(p - c.xy) / r;
float u = clamp((x - 0.56) / 0.44, 0.0, 1.0);
float bowl = pow(1.0 - u, 1.5);
float lip = exp(-pow((x - 1.04) / 0.13, 2.0));
return c.w * uSize.y * (RIM * lip - DEPTH * bowl);
}
float height(vec2 p) {
float inside = -topPill(p);
float h = 0.0;
for (int i = 0; i < 5; i++) h += crater(p, uCrater[i]);
// craters ease off across the chamfer so the slab's outline never changes
float c = uSize.y * CHAMFER;
return slab(p, inside) + h * smoothstep(0.0, c * 1.05, inside);
}
float shadow(vec3 ro, vec3 l) {
float res = 1.0;
float stride = uSize.y * 0.026;
float t = stride * 0.5;
for (int i = 0; i < 16; i++) {
vec3 q = ro + l * t;
float above = height(q.xy) - q.z;
res = min(res, clamp(1.0 - above / (uSize.y * 0.01 + t * 0.14), 0.0, 1.0));
t += stride;
}
return res;
}
float hash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
float vnoise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y);
}
vec3 topColor(vec2 p, float dTop) {
float e = 0.6;
float h = height(p);
float dx = height(p + vec2(e, 0.0)) - height(p - vec2(e, 0.0));
float dy = height(p + vec2(0.0, e)) - height(p - vec2(0.0, e));
vec3 n = normalize(vec3(-dx, -dy, 2.0 * e));
vec3 l = normalize(LIGHT);
float diffuse = max(dot(n, l), 0.0);
float lit = shadow(vec3(p, h + 0.15), l);
float sunk = slab(p, -dTop) - h;
float occlusion = 1.0 - 0.46 * smoothstep(0.0, uSize.y * DEPTH * 0.75, sunk);
// faces tipped toward the viewer (down the screen) catch more of the room's light
vec3 ambient = vec3(1.0, 0.95, 0.88) * 0.6 * (0.8 + 0.2 * n.z + 0.22 * max(n.y, 0.0));
vec3 albedo = vec3(0.985, 0.962, 0.918);
vec3 col = albedo * (ambient * occlusion + vec3(1.0, 0.985, 0.955) * 0.47 * diffuse * lit);
// occluded clay goes warm and brown rather than grey
col *= mix(vec3(0.9, 0.84, 0.76), vec3(1.0), smoothstep(0.5, 1.0, occlusion * (0.6 + 0.4 * lit)));
vec3 hv = normalize(l + vec3(0.0, 0.0, 1.0));
col += vec3(1.0, 0.98, 0.94) * 0.05 * pow(max(dot(n, hv), 0.0), 18.0) * lit;
// a broad falloff of the overhead light across the face
col *= 1.025 - 0.05 * (p.y / faceH());
for (int i = 0; i < 5; i++) {
float g = uGlow[i];
if (g < 0.002) continue;
vec2 c = uCrater[i].xy;
float r = length(p - c) / uSize.y;
// lime bounce on the lower crater wall, facing the lamp
float facing = clamp(dot(n.xy, normalize(c - p + 0.001)) * 2.0, 0.0, 1.0);
col += LIME * g * 0.04 * (1.0 - smoothstep(0.2, 0.3, r)) * facing * smoothstep(0.0, 0.3, -n.y) * smoothstep(0.0, uSize.y * DEPTH * 0.4, sunk);
// and a leak down the lower chamfer
float under = exp(-pow((p.x - c.x) / (uSize.y * 0.24), 2.0)) * smoothstep(0.15, 0.6, n.y)
* step(c.y + faceH() * 0.3, p.y);
col += LIME * g * 0.16 * under;
}
return col;
}
// the front wall: a matte vertical face in the slab's own shade, darkening into the contact
vec3 sideColor(vec2 p) {
float r = faceH() * 0.5;
float dx = max(abs(p.x - uSize.x * 0.5) - (uSize.x * 0.5 - r), 0.0);
float edgeY = r + sqrt(max(r * r - dx * dx, 0.0));
float v = clamp((p.y - edgeY) / (uSize.y * SIDE), 0.0, 1.0);
vec3 col = mix(vec3(0.76, 0.72, 0.65), vec3(0.64, 0.595, 0.525), v);
col *= 1.0 - 0.22 * smoothstep(0.6, 1.0, v);
for (int i = 0; i < 5; i++) {
float g = uGlow[i];
if (g < 0.002) continue;
float spread = exp(-pow((p.x - uCrater[i].x) / (uSize.y * 0.2), 2.0));
col += LIME * g * spread * (0.12 + 0.3 * smoothstep(0.3, 1.0, v));
}
return col;
}
void main() {
vec2 p = vec2(gl_FragCoord.x, uSize.y * uDpr - gl_FragCoord.y) / uDpr;
float dTop = topPill(p);
float dBase = basePill(p);
float alpha = clamp(0.5 - min(dTop, dBase) * uDpr, 0.0, 1.0);
if (alpha <= 0.0) { gl_FragColor = vec4(0.0); return; }
float onTop = clamp(0.5 - dTop * uDpr, 0.0, 1.0);
vec3 col = onTop > 0.0 ? topColor(p, dTop) : vec3(0.0);
if (onTop < 1.0) col = mix(sideColor(p), col, onTop);
// fine clay grain plus a faint large-scale mottling
float grain = hash(gl_FragCoord.xy) - 0.5;
float mottle = vnoise(p / (uSize.y * 0.08)) - 0.5;
col *= 1.0 + grain * 0.035 + mottle * 0.012;
gl_FragColor = vec4(col * alpha, alpha);
}
`;
let uid = 0;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const smoothstep = (a, b, v) => {
const t = clamp((v - a) / (b - a), 0, 1);
return t * t * (3 - 2 * t);
};
class DimpleNav {
constructor(root, { tabs, active = 0, sound = true, onChange } = {}) {
this.root = root;
this.tabs = tabs;
this.active = active;
this.onChange = onChange;
this.sound = sound ? new Clicks() : null;
this.running = false;
this.last = 0;
// per slot: springs for crater radius (s), depth (d) and glow (g), plus timing
this.initial = active;
this.reset();
this.build();
this.gl = this.initGL();
this.layout();
new ResizeObserver(() => { this.layout(); this.paint(); }).observe(root);
this.paint();
}
// back to the starting tab with every spring at rest (a reel replays from here frame by frame)
reset() {
this.active = this.initial;
this.slots = this.tabs.map((_, i) => {
const on = i === this.initial ? 1 : 0;
return { s: on, sv: 0, d: on, dv: 0, g: on, gv: 0, since: -Infinity, pressed: false };
});
if (this.buttons) this.syncAria();
}
// ms timestamp the animation runs on; a reel overrides it with its frame time
clock() {
return performance.now();
}
build() {
this.id = ++uid;
this.root.classList.add('dn');
this.root.innerHTML = `
<div class="dn-glows" aria-hidden="true">${this.tabs.map(() => '<i class="dn-glow"></i>').join('')}</div>
<div class="dn-bar">
<div class="dn-shadow"></div>
<canvas class="dn-surface" aria-hidden="true"></canvas>
<div class="dn-tabs" role="tablist" aria-label="Main"></div>
</div>
`;
this.bar = this.root.querySelector('.dn-bar');
this.canvas = this.root.querySelector('.dn-surface');
this.glows = [...this.root.querySelectorAll('.dn-glow')];
const list = this.root.querySelector('.dn-tabs');
this.buttons = this.tabs.map((tab, i) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'dn-tab';
b.setAttribute('role', 'tab');
b.setAttribute('aria-label', tab.label);
b.innerHTML = `
<span class="dn-icon">${ICONS[tab.icon] || tab.icon}</span>
<span class="dn-switch"><span class="dn-led"></span></span>
`;
b.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
this.slots[i].pressed = true;
b.setPointerCapture(e.pointerId);
this.wake();
});
const release = () => { this.slots[i].pressed = false; this.wake(); };
b.addEventListener('pointerup', release);
b.addEventListener('pointercancel', release);
b.addEventListener('click', () => this.select(i));
b.addEventListener('keydown', (e) => this.onKey(e, i));
list.appendChild(b);
return b;
});
this.icons = this.buttons.map((b) => b.querySelector('.dn-icon'));
this.switches = this.buttons.map((b) => b.querySelector('.dn-switch'));
this.syncAria();
}
initGL() {
const gl = this.canvas.getContext('webgl', { premultipliedAlpha: true, antialias: false, preserveDrawingBuffer: true });
if (!gl) { this.root.classList.add('dn-flat'); return null; }
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
};
try {
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
this.u = {
size: gl.getUniformLocation(prog, 'uSize'),
dpr: gl.getUniformLocation(prog, 'uDpr'),
crater: gl.getUniformLocation(prog, 'uCrater'),
glow: gl.getUniformLocation(prog, 'uGlow'),
};
this.craterData = new Float32Array(20);
this.glowData = new Float32Array(5);
return gl;
} catch (err) {
console.warn('DimpleNav: falling back to a flat bar.', err);
this.root.classList.add('dn-flat');
return null;
}
}
layout() {
const w = this.bar.clientWidth;
const h = w / ASPECT;
this.w = w;
this.h = h;
this.root.style.setProperty('--dn-h', `${h}px`);
this.root.style.setProperty('--dn-face', `${(h * (1 - SIDE)) / 2}px`);
const n = this.tabs.length;
const span = 1 - EDGE * 2;
this.centers = this.tabs.map((_, i) => w * (n === 1 ? 0.5 : EDGE + (span * i) / (n - 1)));
this.buttons.forEach((b, i) => { b.style.left = `${this.centers[i]}px`; });
this.glows.forEach((g, i) => { g.style.left = `${this.centers[i]}px`; });
if (this.gl) {
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.canvas.width = Math.round(w * this.dpr);
this.canvas.height = Math.round(h * this.dpr);
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
}
}
select(i) {
if (i === this.active) return;
const now = this.clock();
this.slots[this.active].since = now; // starts filling in
this.slots[i].since = now; // starts punching
this.active = i;
this.syncAria();
if (this.sound) this.sound.punch();
if (this.sound) setTimeout(() => this.active === i && this.sound.light(), LIGHT_DELAY);
if (reducedMotion.matches) this.slots.forEach((s, j) => this.snap(s, j === i));
this.wake();
if (this.onChange) this.onChange(this.tabs[i], i);
}
onKey(e, i) {
const n = this.tabs.length;
const to = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: n - 1 }[e.key];
if (to === undefined) return;
e.preventDefault();
const j = (to + n) % n;
this.buttons[j].focus();
this.select(j);
}
syncAria() {
this.buttons.forEach((b, i) => {
const on = i === this.active;
b.setAttribute('aria-selected', String(on));
b.tabIndex = on ? 0 : -1;
b.classList.toggle('is-active', on);
});
}
snap(slot, on) {
const v = on ? 1 : 0;
Object.assign(slot, { s: v, sv: 0, d: v, dv: 0, g: v, gv: 0 });
}
// where each spring is heading right now; the delays stagger press → sink → light
targets(slot, i, now) {
const t = now - slot.since;
if (i === this.active) {
return { s: 1, d: t > DEPTH_DELAY ? 1 : PRESS.d * 0.5, g: t > LIGHT_DELAY ? 1 : 0 };
}
if (slot.pressed) return { s: PRESS.s, d: PRESS.d, g: 0 };
return { s: t > FILL_DELAY || slot.d < 0.05 ? 0 : slot.s, d: 0, g: 0 };
}
wake() {
if (this.running) return;
this.running = true;
this.last = performance.now();
requestAnimationFrame((t) => this.tick(t));
}
tick(now) {
const dt = Math.min((now - this.last) / 1000, 1 / 30);
this.last = now;
const moving = this.step(now, dt);
this.paint();
if (moving) requestAnimationFrame((t) => this.tick(t));
else this.running = false;
}
// advances every spring by dt seconds; returns whether anything is still moving
step(now, dt) {
let moving = false;
this.slots.forEach((slot, i) => {
const goal = this.targets(slot, i, now);
for (const k of ['s', 'd', 'g']) {
const [stiff, damp] = SPRINGS[k];
const v = k + 'v';
// two semi-implicit Euler substeps keep the stiff springs stable at low frame rates
for (let n = 0; n < 2; n++) {
const h = dt / 2;
slot[v] += (stiff * (goal[k] - slot[k]) - damp * slot[v]) * h;
slot[k] += slot[v] * h;
}
if (Math.abs(goal[k] - slot[k]) > 0.001 || Math.abs(slot[v]) > 0.001) moving = true;
else { slot[k] = goal[k]; slot[v] = 0; }
}
// a delayed target counts as motion still to come
if (now - slot.since < LIGHT_DELAY + 50 || slot.pressed) moving = true;
});
return moving;
}
paint() {
const h = this.h;
this.slots.forEach((slot, i) => {
const d = Math.max(slot.d, 0);
// a switch sinking away goes dark early, so it never lingers as a stray dot in the old crater
const shown = i === this.active ? smoothstep(0.5, 0.85, d) : smoothstep(0.8, 1, d);
// the icon sinks into the clay as the crater opens beneath it
const sink = smoothstep(0.05, 0.55, d);
const press = slot.pressed && i !== this.active ? 1 : 0;
this.icons[i].style.opacity = 1 - sink;
this.icons[i].style.transform =
`translateY(${(sink * 0.08 + press * 0.012) * h}px) scale(${1 - sink * 0.45 - press * 0.06})`;
this.switches[i].style.opacity = shown;
this.switches[i].style.transform = `translate(-50%, -50%) scale(${0.55 + 0.45 * clamp(slot.d, 0, 1.08)})`;
this.switches[i].style.setProperty('--dn-lit', clamp(slot.g, 0, 1).toFixed(3));
this.glows[i].style.opacity = clamp(slot.g, 0, 1);
});
const gl = this.gl;
if (!gl) return;
this.slots.forEach((slot, i) => {
this.craterData.set([this.centers[i], (h * (1 - SIDE)) / 2, slot.s, slot.d], i * 4);
this.glowData[i] = clamp(slot.g, 0, 1);
});
gl.uniform2f(this.u.size, this.w, h);
gl.uniform1f(this.u.dpr, this.dpr);
gl.uniform4fv(this.u.crater, this.craterData);
gl.uniform1fv(this.u.glow, this.glowData);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
}
/* A muffled thunk as the clay gives way, and a small bright tick when the light comes on.
* Pass an OfflineAudioContext and a start time to render them ahead of time (used for the reel). */
class Clicks {
constructor(ctx) {
this._ctx = ctx;
}
get ctx() {
if (!this._ctx) this._ctx = new (window.AudioContext || window.webkitAudioContext)();
if (this._ctx.state === 'suspended') this._ctx.resume();
return this._ctx;
}
noise(len) {
const ctx = this.ctx;
const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * len), ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buf;
return src;
}
punch(t = this.ctx.currentTime, dest = this.ctx.destination) {
const ctx = this.ctx;
const out = ctx.createGain();
out.gain.value = 0.5;
out.connect(dest);
const body = ctx.createOscillator();
const bodyGain = ctx.createGain();
body.frequency.setValueAtTime(190, t);
body.frequency.exponentialRampToValueAtTime(55, t + 0.09);
bodyGain.gain.setValueAtTime(0.0001, t);
bodyGain.gain.exponentialRampToValueAtTime(0.7, t + 0.006);
bodyGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.14);
body.connect(bodyGain).connect(out);
body.start(t);
body.stop(t + 0.15);
const grit = this.noise(0.05);
const band = ctx.createBiquadFilter();
band.type = 'bandpass';
band.frequency.value = 1400;
band.Q.value = 0.8;
const gritGain = ctx.createGain();
gritGain.gain.setValueAtTime(0.25, t);
gritGain.gain.exponentialRampToValueAtTime(0.0001, t + 0.04);
grit.connect(band).connect(gritGain).connect(out);
grit.start(t);
}
light(t = this.ctx.currentTime, dest = this.ctx.destination) {
const ctx = this.ctx;
const tick = ctx.createOscillator();
const g = ctx.createGain();
tick.type = 'triangle';
tick.frequency.setValueAtTime(2400, t);
tick.frequency.exponentialRampToValueAtTime(1600, t + 0.03);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.08, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.06);
tick.connect(g).connect(dest);
tick.start(t);
tick.stop(t + 0.07);
}
}
DimpleNav.icons = ICONS;
DimpleNav.Clicks = Clicks;
DimpleNav.LIGHT_DELAY = LIGHT_DELAY;
window.DimpleNav = DimpleNav;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dimple 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=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="dimple-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: "JetBrains Mono", ui-monospace, monospace;
/* warm taupe paper, lit from above */
background:
radial-gradient(ellipse 80% 55% at 50% 30%, rgba(232, 225, 212, 0.75), transparent 70%),
radial-gradient(ellipse 120% 90% at 50% 45%, transparent 55%, rgba(120, 104, 82, 0.28)),
linear-gradient(180deg, #d8d0c4, #c8beb0 60%, #b5aa99);
}
/* paper grain */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.22;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.4 0 0 0 0 0.35 0 0 0 0 0.28 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
}
main {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
align-items: center;
}
.hint {
margin: 72px 0 0;
color: #7d7263;
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
text-align: center;
}
</style>
</head>
<body>
<main>
<nav id="nav"></nav>
<p class="hint">Press a tab — the clay gives way</p>
</main>
<script src="dimple-nav.js"></script>
<script>
new DimpleNav(document.getElementById('nav'), {
active: 2,
tabs: [
{ label: 'Home', icon: 'home' },
{ label: 'Apps', icon: 'blocks' },
{ label: 'Activity', icon: 'bell' },
{ label: 'Search', icon: 'search' },
{ label: 'Profile', icon: 'user' },
],
});
</script>
</body>
</html>