№ 012
Inertia Core
A precision slider whose weight you can see — a champagne slug lags behind the handle as you drag.
- Version
- 1.0.0
- Download
- 345 KB · 5 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- slider, inertia, physics
Usage
Copy inertia-core.css and inertia-core.js (and assets/, if there is one) into your project, then:
new InertiaCore(element, {
value: 0.64, // 0..1
label: 'OUTPUT',
titanium: 'assets/titanium.jpg',
graphite: 'assets/graphite.jpg',
onChange(value) {},
})
A precision slider whose weight you can see: a champagne slug sits in a window through the titanium
handle. The handle tracks the pointer exactly; the slug has inertia, so it lags behind as you drag,
swaps sides when you reverse, and settles back to centre with one small, heavily damped movement.
The value always follows the gesture directly — only the slug is simulated.Source
/* Inertia Core — a precision slider. Laid out on a 560-unit-wide grid (--u), so it scales as one piece
with the width of its container. */
.ic {
--ic-ink: #d9d4cb;
--ic-ink-dim: #8f8b84;
--ic-champagne-lt: #f3dfc4;
--ic-champagne: #d2b08a;
--ic-champagne-dk: #8a6a4b;
--ic-titanium: none;
--ic-graphite: none;
--ic-font: "Inter", "Helvetica Neue", Arial, sans-serif;
container-type: inline-size;
position: relative;
width: 100%;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.ic *,
.ic *::before,
.ic *::after {
box-sizing: border-box;
}
.ic-stage {
--u: calc(100cqw / 560);
position: relative;
width: 100%;
height: calc(var(--u) * 116);
cursor: pointer;
touch-action: none;
}
/* ── housing ── */
.ic-housing {
position: absolute;
inset: 0;
border-radius: calc(var(--u) * 7);
/* the matte graphite, softened toward a warm mid-grey */
background: linear-gradient(rgba(74, 74, 71, 0.72), rgba(64, 64, 62, 0.78)), var(--ic-graphite) center / 100% auto, #444542;
box-shadow:
inset 0 0 0 calc(var(--u) * 1) rgba(255, 255, 255, 0.07), /* machined outer rim */
inset 0 calc(var(--u) * 0.8) 0 rgba(255, 255, 255, 0.12), /* fine highlight on the top edge */
inset 0 calc(var(--u) * -1) calc(var(--u) * 1.5) rgba(0, 0, 0, 0.35),
0 calc(var(--u) * 1.5) calc(var(--u) * 2) rgba(40, 36, 30, 0.22),
0 calc(var(--u) * 10) calc(var(--u) * 18) rgba(40, 36, 30, 0.2);
}
.ic-label,
.ic-value,
.ic-end {
position: absolute;
font: 400 calc(var(--u) * 8.5) / 1 var(--ic-font);
letter-spacing: 0.3em;
color: var(--ic-ink);
white-space: nowrap;
}
.ic-label {
left: calc(var(--u) * 26);
top: calc(var(--u) * 15);
}
.ic-value {
right: calc(var(--u) * 25);
margin-right: -0.3em;
top: calc(var(--u) * 15);
font-variant-numeric: tabular-nums;
}
.ic-end {
top: calc(var(--u) * 96);
font-size: calc(var(--u) * 7.5);
letter-spacing: 0.18em;
color: var(--ic-ink-dim);
}
.ic-min { left: calc(var(--u) * 24); }
.ic-max { right: calc(var(--u) * 22); margin-right: -0.18em; }
/* recessed channel with a soft machined rim */
.ic-slot {
position: absolute;
left: calc(var(--u) * 22.5);
right: calc(var(--u) * 27);
top: calc(var(--u) * 36);
height: calc(var(--u) * 37);
border-radius: calc(var(--u) * 4);
background: linear-gradient(#161616, #222223 60%, #262627);
box-shadow:
0 0 0 calc(var(--u) * 1.2) #3c3d3d,
0 calc(var(--u) * 1.4) 0 calc(var(--u) * 1.2) rgba(255, 255, 255, 0.07),
inset 0 calc(var(--u) * 3) calc(var(--u) * 4) rgba(0, 0, 0, 0.6);
}
/* the empty run of track, right of the handle */
.ic-groove {
position: absolute;
left: calc(var(--u) * 6);
right: calc(var(--u) * 6);
top: calc(var(--u) * 13);
height: calc(var(--u) * 11);
border-radius: calc(var(--u) * 2);
background: #2c2c2d;
box-shadow: inset 0 calc(var(--u) * 1) calc(var(--u) * 1.5) rgba(0, 0, 0, 0.5), 0 calc(var(--u) * 0.6) 0 rgba(255, 255, 255, 0.04);
}
/* the champagne rod, from the left end up to the handle */
.ic-rail {
position: absolute;
left: calc(var(--u) * 7.5);
top: calc(var(--u) * 17);
height: calc(var(--u) * 3.2);
border-radius: calc(var(--u) * 1.6);
background: linear-gradient(var(--ic-champagne-lt), var(--ic-champagne) 55%, var(--ic-champagne-dk));
box-shadow: 0 calc(var(--u) * 1) calc(var(--u) * 1.5) rgba(0, 0, 0, 0.55);
}
.ic-scale {
position: absolute;
inset: 0;
}
.ic-tick {
position: absolute;
top: calc(var(--u) * 85);
width: calc(var(--u) * 0.7);
height: calc(var(--u) * 4);
margin-left: calc(var(--u) * -0.35);
background: #77746f;
}
.ic-tick.is-major {
top: calc(var(--u) * 83);
height: calc(var(--u) * 7);
background: #a9a59e;
}
/* ── handle: a titanium carriage with a window through its centre ── */
.ic-handle {
position: absolute;
left: 0;
top: calc(var(--u) * 14);
width: calc(var(--u) * 49);
height: calc(var(--u) * 81);
border-radius: calc(var(--u) * 2.4);
background:
linear-gradient(rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 18%, rgba(0, 0, 0, 0) 58%, rgba(0, 0, 0, 0.42)),
linear-gradient(rgba(120, 106, 88, 0.32), rgba(120, 106, 88, 0.32)), /* warm gunmetal tint */
var(--ic-titanium) center / cover,
#9a968f;
background-blend-mode: normal, multiply, normal;
box-shadow:
inset 0 calc(var(--u) * 1) 0 rgba(255, 255, 255, 0.75), /* fine highlight across the top edge */
inset calc(var(--u) * 1) 0 0 rgba(255, 255, 255, 0.22),
inset calc(var(--u) * -1) 0 0 rgba(0, 0, 0, 0.25),
inset 0 calc(var(--u) * -1.6) 0 rgba(0, 0, 0, 0.45), /* chamfer on the lower edge */
0 calc(var(--u) * 1) calc(var(--u) * 1) rgba(0, 0, 0, 0.5),
0 calc(var(--u) * 6) calc(var(--u) * 9) rgba(0, 0, 0, 0.45);
outline: none;
cursor: grab;
}
.is-dragging .ic-handle { cursor: grabbing; }
.ic-handle:focus-visible {
box-shadow:
0 0 0 calc(var(--u) * 1.5) #1f1f1f,
0 0 0 calc(var(--u) * 3) var(--ic-champagne),
inset 0 calc(var(--u) * 1) 0 rgba(255, 255, 255, 0.75),
0 calc(var(--u) * 6) calc(var(--u) * 9) rgba(0, 0, 0, 0.45);
}
/* two machined grooves above and below the window */
.ic-handle::before,
.ic-handle::after {
content: "";
position: absolute;
left: 50%;
width: calc(var(--u) * 10);
height: calc(var(--u) * 21);
margin-left: calc(var(--u) * -5);
background:
linear-gradient(to right,
rgba(0, 0, 0, 0.6) 0 calc(var(--u) * 1.6), rgba(255, 255, 255, 0.5) 0 calc(var(--u) * 2.3), transparent 0 calc(var(--u) * 7),
rgba(0, 0, 0, 0.6) 0 calc(var(--u) * 8.6), rgba(255, 255, 255, 0.5) 0 calc(var(--u) * 9.3), transparent 0);
}
.ic-handle::before { top: calc(var(--u) * 6); }
.ic-handle::after { bottom: calc(var(--u) * 6); }
.ic-window {
position: absolute;
left: calc(var(--u) * 5.5);
right: calc(var(--u) * 5.5);
top: calc(var(--u) * 33);
height: calc(var(--u) * 15);
border-radius: calc(var(--u) * 2);
background: linear-gradient(#0c0c0c, #1b1b1b);
box-shadow:
inset 0 calc(var(--u) * 1.6) calc(var(--u) * 2) rgba(0, 0, 0, 0.9),
0 calc(var(--u) * 0.8) 0 rgba(255, 255, 255, 0.35),
0 calc(var(--u) * -0.8) 0 rgba(0, 0, 0, 0.35);
overflow: hidden;
}
/* the weight: a small solid champagne slug */
.ic-core {
position: absolute;
left: 50%;
top: calc(var(--u) * 2);
width: calc(var(--u) * 17);
height: calc(var(--u) * 11);
margin-left: calc(var(--u) * -8.5);
border-radius: calc(var(--u) * 1.6);
background:
repeating-linear-gradient(to right, rgba(70, 48, 28, 0.28) 0 calc(var(--u) * 0.5), transparent 0 calc(var(--u) * 2.2)) center / calc(100% - var(--u) * 6) 100% no-repeat,
linear-gradient(var(--ic-champagne-lt), #e2c49f 30%, var(--ic-champagne) 55%, var(--ic-champagne-dk));
box-shadow:
inset 0 calc(var(--u) * 0.6) 0 rgba(255, 255, 255, 0.6),
inset 0 calc(var(--u) * -0.8) 0 rgba(60, 40, 22, 0.5),
0 0 calc(var(--u) * 2) rgba(0, 0, 0, 0.8);
} /* Inertia Core — usage:
* new InertiaCore(element, {
* value: 0.64, // 0..1
* label: 'OUTPUT',
* titanium: 'assets/titanium.jpg',
* graphite: 'assets/graphite.jpg',
* onChange(value) {},
* })
*
* A precision slider whose weight you can see: a champagne slug sits in a window through the titanium
* handle. The handle tracks the pointer exactly; the slug has inertia, so it lags behind as you drag,
* swaps sides when you reverse, and settles back to centre with one small, heavily damped movement.
* The value always follows the gesture directly — only the slug is simulated.
*/
(function () {
// Geometry, in units of a 560-unit-wide housing (see inertia-core.css).
const TRACK_LEFT = 31; // handle centre at 0%
const TRACK_SPAN = 498; // handle centre travel from 0% to 100%
const HANDLE_W = 49;
// The slug: a spring–damper tied to the handle.
const OMEGA = 24; // natural frequency (rad/s)
const ZETA = 0.72; // damping ratio: settles in ~230 ms with one faint overshoot
const MAX = 7; // furthest the slug shows off centre (units)
const LIMIT = 10; // where it meets the window wall (units)
const GLIDE = 0.18; // keyboard and track presses glide the handle over this long (s)
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const easeOut = (t) => 1 - (1 - t) ** 3;
// The slug on its own, so the reel can run exactly the same physics frame by frame.
class Core {
constructor() {
this.x = 0; // slug offset from the handle centre (units); negative = left
this.v = 0; // its velocity relative to the handle
this.h = null; // last handle position
this.hv = 0; // handle velocity
}
// Advance by dt with the handle now at h (units). Substeps keep it stable at any frame rate.
step(h, dt) {
if (this.h === null || dt <= 0) {
this.h = h;
return this.offset;
}
const n = Math.max(1, Math.ceil(dt / (1 / 240)));
const sub = dt / n;
const h0 = this.h;
for (let i = 1; i <= n; i++) {
const hi = h0 + ((h - h0) * i) / n; // the handle moves evenly through the frame
const hv = (hi - this.h) / sub;
const ha = (hv - this.hv) / sub;
this.h = hi;
this.hv = hv;
// relative motion: the spring pulls the slug to centre, the handle's acceleration pushes it back
const a = -OMEGA * OMEGA * this.x - 2 * ZETA * OMEGA * this.v - ha;
this.v += a * sub;
this.x += this.v * sub;
if (Math.abs(this.x) > LIMIT) { // stopped by the window wall
this.x = Math.sign(this.x) * LIMIT;
this.v = 0;
}
}
return this.offset;
}
// Shown offset: soft near the wall, so it eases in instead of clipping.
get offset() {
return MAX * Math.tanh(this.x / MAX);
}
get resting() {
return Math.abs(this.x) < 0.01 && Math.abs(this.v) < 0.05 && Math.abs(this.hv) < 0.05;
}
reset() {
this.x = this.v = this.hv = 0;
this.h = null;
}
}
class InertiaCore {
constructor(root, { value = 0.64, label = 'OUTPUT', titanium, graphite, onChange } = {}) {
this.root = root;
this.value = clamp(value, 0, 1);
this.shown = this.value; // where the handle is drawn (differs from value only during a glide)
this.glide = null;
this.onChange = onChange;
this.core = new Core();
this.running = false;
this.build(label, titanium, graphite);
this.paint(this.shown, 0);
}
static handleAt(value) {
return TRACK_LEFT + value * TRACK_SPAN;
}
build(label, titanium, graphite) {
this.root.classList.add('ic');
const ticks = Array.from({ length: 41 }, (_, i) =>
`<span class="ic-tick${i % 5 === 0 ? ' is-major' : ''}" style="left: calc(var(--u) * ${(TRACK_LEFT + (i / 40) * TRACK_SPAN).toFixed(2)})"></span>`).join('');
this.root.innerHTML = `
<div class="ic-stage">
<div class="ic-housing">
<span class="ic-label">${label}</span>
<span class="ic-value" aria-hidden="true"></span>
<div class="ic-slot">
<span class="ic-groove"></span>
<span class="ic-rail"></span>
</div>
<div class="ic-scale" aria-hidden="true">${ticks}</div>
<span class="ic-end ic-min">MIN</span>
<span class="ic-end ic-max">MAX</span>
</div>
<div class="ic-handle" role="slider" tabindex="0" aria-label="${label.toLowerCase()}" aria-valuemin="0" aria-valuemax="100">
<span class="ic-window"><span class="ic-core"></span></span>
</div>
</div>`;
const q = (s) => this.root.querySelector(s);
this.stage = q('.ic-stage');
this.handle = q('.ic-handle');
this.coreEl = q('.ic-core');
this.rail = q('.ic-rail');
this.valueEl = q('.ic-value');
if (titanium) this.handle.style.setProperty('--ic-titanium', `url("${titanium}")`);
if (graphite) q('.ic-housing').style.setProperty('--ic-graphite', `url("${graphite}")`);
// dragging: grab the handle anywhere and it keeps its offset under the pointer;
// press the housing elsewhere and the handle glides there, then follows
const unitsAt = (e) => {
const r = this.stage.getBoundingClientRect();
return ((e.clientX - r.left) / r.width) * 560;
};
let grab = 0;
this.stage.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault();
this.stage.setPointerCapture(e.pointerId);
this.handle.focus({ preventScroll: true });
const x = unitsAt(e);
const h = InertiaCore.handleAt(this.shown);
if (Math.abs(x - h) <= HANDLE_W / 2) grab = x - h;
else {
grab = 0;
this.glideTo((x - TRACK_LEFT) / TRACK_SPAN);
}
this.root.classList.add('is-dragging');
});
this.stage.addEventListener('pointermove', (e) => {
if (!this.stage.hasPointerCapture(e.pointerId)) return;
this.glide = null;
this.set((unitsAt(e) - grab - TRACK_LEFT) / TRACK_SPAN);
});
const end = () => this.root.classList.remove('is-dragging');
this.stage.addEventListener('pointerup', end);
this.stage.addEventListener('pointercancel', end);
this.handle.addEventListener('keydown', (e) => {
const big = e.shiftKey ? 0.1 : 0.01;
const to = {
ArrowRight: this.value + big, ArrowUp: this.value + big,
ArrowLeft: this.value - big, ArrowDown: this.value - big,
PageUp: this.value + 0.1, PageDown: this.value - 0.1,
Home: 0, End: 1,
}[e.key];
if (to === undefined) return;
e.preventDefault();
this.glideTo(to);
});
}
// The value and the handle move together, immediately.
set(v) {
this.setValue(v);
this.shown = this.value;
this.loop();
}
// The value changes now; the handle glides over to it (keyboard, presses on the track).
glideTo(v) {
this.setValue(v);
this.glide = { from: this.shown, to: this.value, start: performance.now() };
this.loop();
}
setValue(v) {
v = clamp(Math.round(v * 1000) / 1000, 0, 1);
if (v === this.value) return;
this.value = v;
this.onChange?.(v);
}
// Draws the handle at value `shown` and the slug at `offset` units from centre.
paint(shown, offset) {
const h = InertiaCore.handleAt(shown);
this.handle.style.transform = `translateX(calc(var(--u) * ${(h - HANDLE_W / 2).toFixed(3)}))`;
this.rail.style.width = `calc(var(--u) * ${(h - HANDLE_W / 2 - 30 + 1).toFixed(3)})`;
this.coreEl.style.transform = `translateX(calc(var(--u) * ${offset.toFixed(3)}))`;
const pct = Math.round(this.value * 100);
this.valueEl.textContent = `${pct}%`;
this.handle.setAttribute('aria-valuenow', String(pct));
this.handle.setAttribute('aria-valuetext', `${pct}%`);
}
/* ── animation: only runs while the handle or the slug is moving ── */
loop() {
if (this.running) return;
this.running = true;
let last = performance.now();
const tick = (now) => {
const dt = Math.min((now - last) / 1000, 1 / 20);
last = now;
if (this.glide) {
const p = clamp((now - this.glide.start) / 1000 / GLIDE, 0, 1);
this.shown = this.glide.from + (this.glide.to - this.glide.from) * easeOut(p);
if (p >= 1) this.glide = null;
}
let offset = 0;
if (reducedMotion.matches) this.core.reset();
else offset = this.core.step(InertiaCore.handleAt(this.shown), dt);
this.paint(this.shown, offset);
const dragging = this.root.classList.contains('is-dragging');
if (!dragging && !this.glide && this.core.resting) {
this.core.reset();
this.paint(this.shown, 0);
this.running = false;
return;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
}
InertiaCore.Core = Core;
window.InertiaCore = InertiaCore;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Inertia Core</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=Cormorant+Garamond:wght@500&family=Inter:wght@400&display=swap" rel="stylesheet">
<link rel="stylesheet" href="inertia-core.css">
<style>
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
padding: 16px;
overflow: hidden;
/* warm stone grey */
background: #d0ccc4;
font-family: "Cormorant Garamond", Georgia, serif;
color: #2f2c2a;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 680px;
gap: 64px;
}
.title,
.footer {
margin: 0;
font-weight: 500;
letter-spacing: 0.32em;
margin-right: -0.32em;
}
.title { font-size: 22px; }
.footer { font-size: 15px; color: #5c5751; }
</style>
</head>
<body>
<main class="page">
<p class="title">THE INERTIA CORE</p>
<div id="slider" style="width: 100%"></div>
<p class="footer">PRECISION YOU CAN FEEL.</p>
</main>
<script src="inertia-core.js"></script>
<script>
new InertiaCore(document.getElementById('slider'), {
value: 0.64,
titanium: 'assets/titanium.jpg',
graphite: 'assets/graphite.jpg',
});
</script>
</body>
</html> All 5 files in the zip
-
assets/graphite.jpg -
assets/titanium.jpg -
index.html -
inertia-core.css -
inertia-core.js