Foam
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const N = 121;
const TOP = 30;
const BOT = 50;
const T = 18;
const SIGMA = 9;
const at = (v: number) => 10 + (v / 100) * (W - 20);
const dentAt = (i: number, x: number, depth: number) => depth * Math.exp(-((i * 2 - x) ** 2) / (2 * SIGMA * SIGMA));
const css = `
.slider-foam { position: relative; width: ${W}px; height: 76px; }
.slider-foam__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 54px; overflow: visible; }
.slider-foam__base { fill: light-dark(#dbdde1, #2e3035); }
.slider-foam__pore { fill: light-dark(#cbced3, #393c41); }
.slider-foam__base--on { fill: light-dark(#5f626a, #a7abb2); }
.slider-foam__pore--on { fill: light-dark(#50535b, #c6c8cd); }
.slider-foam__puck { fill: light-dark(#373a40, #f1f2f3); }
.slider-foam__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-foam:has(.slider-foam__input:focus-visible) .slider-foam__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-foam__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-foam__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-foam__input { position: absolute; left: 0; top: 0; width: 100%; height: 54px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-foam__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 54px; }
.slider-foam__input::-moz-range-thumb { width: 20px; height: 54px; border: 0; }
`;
export function FoamSlider() {
const [v, setV] = useState(44);
const [, setFrame] = useState(0);
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(44), vx: 0, goal: at(44), held: false, raf: 0, on: false, mem: Array.from({ length: N }, (_, i) => dentAt(i, at(44), 5)) });
// Memory foam: the dent under the puck is pressed in at once, but where the puck has been it creeps back up.
const kick = () => {
const q = st.current;
if (q.on) return;
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
q.on = true;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (still) {
q.x = q.goal;
q.vx = 0;
} else {
q.vx += (-160 * (q.x - q.goal) - 24 * q.vx) * dt;
q.x += q.vx * dt;
}
const depth = q.held ? 8 : 5;
const fade = still ? 0 : Math.exp(-dt / 0.8);
let busy = Math.abs(q.x - q.goal) > 0.05 || Math.abs(q.vx) > 0.5;
for (let i = 0; i < N; i++) {
const dent = dentAt(i, q.x, depth);
q.mem[i] = Math.max(dent, q.mem[i] * fade);
if (q.mem[i] - dent > 0.03) busy = true;
}
setFrame((f) => f + 1);
if (busy) q.raf = requestAnimationFrame(step);
else q.on = false;
};
q.raf = requestAnimationFrame(step);
};
useEffect(() => {
st.current.goal = at(v);
kick();
}, [v]);
useEffect(() => () => cancelAnimationFrame(st.current.raf), []);
const q = st.current;
let surface = `M 0 ${TOP + q.mem[0]}`;
for (let i = 1; i < N; i++) surface += ` L ${i * 2} ${(TOP + q.mem[i]).toFixed(2)}`;
surface += ` L ${W} ${BOT} L 0 ${BOT} Z`;
const sink = q.mem[Math.round(Math.min(W, Math.max(0, q.x)) / 2)];
const hold = (held: boolean) => {
q.held = held;
kick();
};
return (
<>
<style>
{css}
</style>
<div className="slider-foam">
<svg className="slider-foam__svg" aria-hidden="true">
<defs>
<pattern id={`${id}a`} patternUnits="userSpaceOnUse" width={6} height={6}>
<rect className="slider-foam__base" width={6} height={6} />
<circle className="slider-foam__pore" cx={1.5} cy={1.5} r={0.9} />
<circle className="slider-foam__pore" cx={4.5} cy={4.5} r={0.7} />
</pattern>
<pattern id={`${id}b`} patternUnits="userSpaceOnUse" width={6} height={6}>
<rect className="slider-foam__base--on" width={6} height={6} />
<circle className="slider-foam__pore--on" cx={1.5} cy={1.5} r={0.9} />
<circle className="slider-foam__pore--on" cx={4.5} cy={4.5} r={0.7} />
</pattern>
<clipPath id={`${id}c`}>
<rect x={1} y={TOP - 2} width={W - 2} height={BOT - TOP + 2} rx={10} />
</clipPath>
<clipPath id={`${id}d`}>
<rect x={0} y={0} width={q.x} height={54} />
</clipPath>
</defs>
<g clipPath={`url(#${id}c)`}>
<path className="slider-foam__surface" d={surface} fill={`url(#${id}a)`} />
<path className="slider-foam__surface slider-foam__surface--on" d={surface} fill={`url(#${id}b)`} clipPath={`url(#${id}d)`} />
</g>
<circle className="slider-foam__puck" cx={q.x} cy={TOP + sink - T / 2 + 1} r={T / 2} />
<circle className="slider-foam__ring" cx={q.x} cy={TOP + sink - T / 2 + 1} r={T / 2 + 4} />
</svg>
<p className="slider-foam__value" aria-hidden="true">
<span>Softness</span>
<b>{v}%</b>
</p>
<input
className="slider-foam__input"
type="range"
min={0}
max={100}
value={v}
onPointerDown={() => hold(true)}
onPointerUp={() => hold(false)}
onPointerCancel={() => hold(false)}
onChange={(e) => setV(+e.target.value)}
aria-label="Softness"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderFoam(el) on the mounted element instead.
<div class="slider-foam">
<svg class="slider-foam__svg" aria-hidden="true">
<defs>
<pattern id="slider-foam-foam" patternUnits="userSpaceOnUse" width="6" height="6">
<rect class="slider-foam__base" width="6" height="6"></rect>
<circle class="slider-foam__pore" cx="1.5" cy="1.5" r="0.9"></circle>
<circle class="slider-foam__pore" cx="4.5" cy="4.5" r="0.7"></circle>
</pattern>
<pattern id="slider-foam-pressed" patternUnits="userSpaceOnUse" width="6" height="6">
<rect class="slider-foam__base--on" width="6" height="6"></rect>
<circle class="slider-foam__pore--on" cx="1.5" cy="1.5" r="0.9"></circle>
<circle class="slider-foam__pore--on" cx="4.5" cy="4.5" r="0.7"></circle>
</pattern>
<clipPath id="slider-foam-clip">
<rect x="1" y="28" width="238" height="22" rx="10"></rect>
</clipPath>
<clipPath id="slider-foam-done">
<rect x="0" y="0" width="106.8" height="54"></rect>
</clipPath>
</defs>
<g clip-path="url(#slider-foam-clip)">
<path class="slider-foam__surface" d="M 0 30 L 2 30.00 L 4 30.00 L 6 30.00 L 8 30.00 L 10 30.00 L 12 30.00 L 14 30.00 L 16 30.00 L 18 30.00 L 20 30.00 L 22 30.00 L 24 30.00 L 26 30.00 L 28 30.00 L 30 30.00 L 32 30.00 L 34 30.00 L 36 30.00 L 38 30.00 L 40 30.00 L 42 30.00 L 44 30.00 L 46 30.00 L 48 30.00 L 50 30.00 L 52 30.00 L 54 30.00 L 56 30.00 L 58 30.00 L 60 30.00 L 62 30.00 L 64 30.00 L 66 30.00 L 68 30.00 L 70 30.00 L 72 30.00 L 74 30.01 L 76 30.01 L 78 30.03 L 80 30.06 L 82 30.11 L 84 30.20 L 86 30.35 L 88 30.56 L 90 30.88 L 92 31.29 L 94 31.82 L 96 32.43 L 98 33.10 L 100 33.76 L 102 34.34 L 104 34.76 L 106 34.98 L 108 34.96 L 110 34.69 L 112 34.23 L 114 33.63 L 116 32.97 L 118 32.31 L 120 31.71 L 122 31.20 L 124 30.81 L 126 30.51 L 128 30.31 L 130 30.18 L 132 30.10 L 134 30.05 L 136 30.03 L 138 30.01 L 140 30.01 L 142 30.00 L 144 30.00 L 146 30.00 L 148 30.00 L 150 30.00 L 152 30.00 L 154 30.00 L 156 30.00 L 158 30.00 L 160 30.00 L 162 30.00 L 164 30.00 L 166 30.00 L 168 30.00 L 170 30.00 L 172 30.00 L 174 30.00 L 176 30.00 L 178 30.00 L 180 30.00 L 182 30.00 L 184 30.00 L 186 30.00 L 188 30.00 L 190 30.00 L 192 30.00 L 194 30.00 L 196 30.00 L 198 30.00 L 200 30.00 L 202 30.00 L 204 30.00 L 206 30.00 L 208 30.00 L 210 30.00 L 212 30.00 L 214 30.00 L 216 30.00 L 218 30.00 L 220 30.00 L 222 30.00 L 224 30.00 L 226 30.00 L 228 30.00 L 230 30.00 L 232 30.00 L 234 30.00 L 236 30.00 L 238 30.00 L 240 30.00 L 240 50 L 0 50 Z" fill="url(#slider-foam-foam)"></path>
<path class="slider-foam__surface slider-foam__surface--on" d="M 0 30 L 2 30.00 L 4 30.00 L 6 30.00 L 8 30.00 L 10 30.00 L 12 30.00 L 14 30.00 L 16 30.00 L 18 30.00 L 20 30.00 L 22 30.00 L 24 30.00 L 26 30.00 L 28 30.00 L 30 30.00 L 32 30.00 L 34 30.00 L 36 30.00 L 38 30.00 L 40 30.00 L 42 30.00 L 44 30.00 L 46 30.00 L 48 30.00 L 50 30.00 L 52 30.00 L 54 30.00 L 56 30.00 L 58 30.00 L 60 30.00 L 62 30.00 L 64 30.00 L 66 30.00 L 68 30.00 L 70 30.00 L 72 30.00 L 74 30.01 L 76 30.01 L 78 30.03 L 80 30.06 L 82 30.11 L 84 30.20 L 86 30.35 L 88 30.56 L 90 30.88 L 92 31.29 L 94 31.82 L 96 32.43 L 98 33.10 L 100 33.76 L 102 34.34 L 104 34.76 L 106 34.98 L 108 34.96 L 110 34.69 L 112 34.23 L 114 33.63 L 116 32.97 L 118 32.31 L 120 31.71 L 122 31.20 L 124 30.81 L 126 30.51 L 128 30.31 L 130 30.18 L 132 30.10 L 134 30.05 L 136 30.03 L 138 30.01 L 140 30.01 L 142 30.00 L 144 30.00 L 146 30.00 L 148 30.00 L 150 30.00 L 152 30.00 L 154 30.00 L 156 30.00 L 158 30.00 L 160 30.00 L 162 30.00 L 164 30.00 L 166 30.00 L 168 30.00 L 170 30.00 L 172 30.00 L 174 30.00 L 176 30.00 L 178 30.00 L 180 30.00 L 182 30.00 L 184 30.00 L 186 30.00 L 188 30.00 L 190 30.00 L 192 30.00 L 194 30.00 L 196 30.00 L 198 30.00 L 200 30.00 L 202 30.00 L 204 30.00 L 206 30.00 L 208 30.00 L 210 30.00 L 212 30.00 L 214 30.00 L 216 30.00 L 218 30.00 L 220 30.00 L 222 30.00 L 224 30.00 L 226 30.00 L 228 30.00 L 230 30.00 L 232 30.00 L 234 30.00 L 236 30.00 L 238 30.00 L 240 30.00 L 240 50 L 0 50 Z" fill="url(#slider-foam-pressed)" clip-path="url(#slider-foam-done)"></path>
</g>
<circle class="slider-foam__puck" cx="106.8" cy="26.98" r="9"></circle>
<circle class="slider-foam__ring" cx="106.8" cy="26.98" r="13"></circle>
</svg>
<p class="slider-foam__value" aria-hidden="true">
<span>Softness</span>
<b>44%</b>
</p>
<input class="slider-foam__input" type="range" min="0" max="100" aria-label="Softness" aria-valuetext="44%" value="44"/>
</div>.slider-foam { position: relative; width: 240px; height: 76px; }
.slider-foam__svg { position: absolute; left: 0; top: 0; width: 240px; height: 54px; overflow: visible; }
.slider-foam__base { fill: light-dark(#dbdde1, #2e3035); }
.slider-foam__pore { fill: light-dark(#cbced3, #393c41); }
.slider-foam__base--on { fill: light-dark(#5f626a, #a7abb2); }
.slider-foam__pore--on { fill: light-dark(#50535b, #c6c8cd); }
.slider-foam__puck { fill: light-dark(#373a40, #f1f2f3); }
.slider-foam__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-foam:has(.slider-foam__input:focus-visible) .slider-foam__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-foam__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-foam__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-foam__input { position: absolute; left: 0; top: 0; width: 100%; height: 54px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-foam__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 54px; }
.slider-foam__input::-moz-range-thumb { width: 20px; height: 54px; border: 0; }function initSliderFoam(root) {
var W = 240;
var N = 121;
var TOP = 30;
var BOT = 50;
var T = 18;
var SIGMA = 9;
var input = root.querySelector('.slider-foam__input');
var surfaces = root.querySelectorAll('.slider-foam__surface');
var done = root.querySelector('#slider-foam-done rect');
var puck = root.querySelector('.slider-foam__puck');
var ring = root.querySelector('.slider-foam__ring');
var label = root.querySelector('.slider-foam__value b');
var x = at(+input.value);
var vx = 0;
var goal = x;
var held = false;
var on = false;
var raf = 0;
var mem = [];
for (var i = 0; i < N; i++) mem.push(dentAt(i, x, 5));
function at(v) { return 10 + (v / 100) * (W - 20); }
function dentAt(i, cx, depth) { return depth * Math.exp(-Math.pow(i * 2 - cx, 2) / (2 * SIGMA * SIGMA)); }
function draw() {
var d = 'M 0 ' + (TOP + mem[0]);
for (var i = 1; i < N; i++) d += ' L ' + i * 2 + ' ' + (TOP + mem[i]).toFixed(2);
d += ' L ' + W + ' ' + BOT + ' L 0 ' + BOT + ' Z';
surfaces.forEach(function (s) { s.setAttribute('d', d); });
done.setAttribute('width', x);
var cy = TOP + mem[Math.round(Math.min(W, Math.max(0, x)) / 2)] - T / 2 + 1;
[puck, ring].forEach(function (c) {
c.setAttribute('cx', x);
c.setAttribute('cy', cy);
});
}
// Memory foam: the dent under the puck is pressed in at once, but where the puck has been it creeps back up.
function kick() {
if (on) return;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
on = true;
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (still) {
x = goal;
vx = 0;
} else {
vx += (-160 * (x - goal) - 24 * vx) * dt;
x += vx * dt;
}
var depth = held ? 8 : 5;
var fade = still ? 0 : Math.exp(-dt / 0.8);
var busy = Math.abs(x - goal) > 0.05 || Math.abs(vx) > 0.5;
for (var i = 0; i < N; i++) {
var dent = dentAt(i, x, depth);
mem[i] = Math.max(dent, mem[i] * fade);
if (mem[i] - dent > 0.03) busy = true;
}
draw();
if (busy) raf = requestAnimationFrame(step);
else on = false;
}
raf = requestAnimationFrame(step);
}
function update() {
goal = at(+input.value);
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
kick();
}
function down() {
held = true;
kick();
}
function up() {
held = false;
kick();
}
input.addEventListener('input', update);
input.addEventListener('pointerdown', down);
input.addEventListener('pointerup', up);
input.addEventListener('pointercancel', up);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
input.removeEventListener('pointerdown', down);
input.removeEventListener('pointerup', up);
input.removeEventListener('pointercancel', up);
};
}
document.querySelectorAll('.slider-foam').forEach(initSliderFoam);Goal: Create a "Foam" slider.
Visual behavior: The track is a slab of memory foam: the thumb presses a dent into it, deeper while you hold it, and where the thumb has just been the dent fills back in slowly.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The top surface is sampled every 2px. Each frame every sample becomes the larger of the dent under the thumb (a Gaussian, deeper while pressed) and its previous depth decayed with a time constant near a second, so the dent follows the thumb at once but leaves a slow trail. The loop stops once the surface matches the dent.
- The slab is one path filled with a pore pattern, drawn twice and clipped at the thumb so the pressed side is darker. The puck sits at the bottom of the dent.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the puck. Under prefers-reduced-motion the dent moves with the thumb and leaves no trail.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Foam" slider.
Visual behavior: The track is a slab of memory foam: the thumb presses a dent into it, deeper while you hold it, and where the thumb has just been the dent fills back in slowly.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The top surface is sampled every 2px. Each frame every sample becomes the larger of the dent under the thumb (a Gaussian, deeper while pressed) and its previous depth decayed with a time constant near a second, so the dent follows the thumb at once but leaves a slow trail. The loop stops once the surface matches the dent.
- The slab is one path filled with a pore pattern, drawn twice and clipped at the thumb so the pressed side is darker. The puck sits at the bottom of the dent.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the puck. Under prefers-reduced-motion the dent moves with the thumb and leaves no trail.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
The track is a slab of memory foam: the thumb presses a dent into it, deeper while you hold it, and where the thumb has just been the dent fills back in slowly.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring, continuous
- Techniques
- svg, spring, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the puck. Under prefers-reduced-motion the dent moves with the thumb and leaves no trail.