Firmness
import { useEffect, useId, useRef, useState } from 'react';
// Spring stiffness and damping per surface (per unit mass), and how widely the dip spreads.
const OPTIONS = [
{ value: 'soft', label: 'Soft', k: 260, c: 9, spread: 2.2, note: 'Sinks in deep and settles slowly' },
{ value: 'medium', label: 'Medium', k: 900, c: 14, spread: 1.6, note: 'Gives a little, then holds' },
{ value: 'firm', label: 'Firm', k: 4000, c: 22, spread: 1.2, note: 'Barely gives, and bounces' },
];
const G = 1400;
const S = 80;
const R = 11;
const DROP = 64;
// The mattress top, dipped under the ball, and where the ball sits for a height h above the surface.
function shape(h: number, spread: number) {
const d = Math.max(0, -h);
const w = 16 + d * spread;
return {
bed: `M20 ${S}L${(110 - w).toFixed(1)} ${S}Q110 ${(S + 2 * d).toFixed(1)} ${(110 + w).toFixed(1)} ${S}L200 ${S}V104Q200 108 196 108H24Q20 108 20 104Z`,
cy: (S - h - R).toFixed(1),
shadow: Math.max(0, 1 - h / 70),
};
}
const REST = shape(-G / OPTIONS[1].k, OPTIONS[1].spread);
const css = `
.radio-firmness { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-firmness__stage { display: block; width: 220px; height: 112px; overflow: visible; }
.radio-firmness__bed { fill: light-dark(#dbdde1, #2e3035); }
.radio-firmness__seam { stroke: light-dark(#ebedf0, #232529); stroke-width: 1.5; stroke-dasharray: 3 3; }
.radio-firmness__ball { fill: light-dark(#42454c, #e5e6e9); }
.radio-firmness__shadow { fill: light-dark(#fbfcfd, #121417); }
.radio-firmness__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.radio-firmness__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
.radio-firmness:has(.radio-firmness__opt:nth-child(1) input:checked) .radio-firmness__read > :nth-child(1),
.radio-firmness:has(.radio-firmness__opt:nth-child(2) input:checked) .radio-firmness__read > :nth-child(2),
.radio-firmness:has(.radio-firmness__opt:nth-child(3) input:checked) .radio-firmness__read > :nth-child(3) { opacity: 1; transform: none; }
.radio-firmness__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-firmness__opt { position: relative; flex: 1; padding: 7px 12px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
.radio-firmness__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-firmness__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-firmness__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-firmness__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-firmness__read > * { transition: none; }
}
`;
export function FirmnessRadio() {
const name = useId();
const [pick, setPick] = useState(1);
const bed = useRef<SVGPathElement>(null);
const ball = useRef<SVGCircleElement>(null);
const shadow = useRef<SVGEllipseElement>(null);
const first = useRef(true);
// Each pick drops the ball onto the new surface. In the air only gravity acts; once it presses
// in, the surface pushes back like a damped spring, so a soft one swallows the ball and a firm
// one throws it back up.
useEffect(() => {
const o = OPTIONS[pick];
const draw = (h: number) => {
const s = shape(h, o.spread);
bed.current!.setAttribute('d', s.bed);
ball.current!.setAttribute('cy', s.cy);
shadow.current!.style.opacity = String(s.shadow * 0.5);
};
const rest = -G / o.k;
if (first.current || matchMedia('(prefers-reduced-motion: reduce)').matches) {
first.current = false;
return draw(rest);
}
let h = DROP;
let v = 0;
let raf = 0;
let last = performance.now();
const t0 = last;
const step = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
for (let k = 0; k < 8; k++) {
const a = -G + (h < 0 ? -o.k * h - o.c * v : 0);
v += a * (dt / 8);
h += v * (dt / 8);
}
if (now - t0 > 400 && Math.abs(v) < 3 && Math.abs(h - rest) < 0.15) return draw(rest);
draw(h);
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [pick]);
return (
<>
<style>
{css}
</style>
<div className="radio-firmness">
<svg className="radio-firmness__stage" viewBox="0 0 220 112" aria-hidden="true">
<path className="radio-firmness__bed" ref={bed} d={REST.bed} />
{[56, 83, 137, 164].map((x) => (
<line key={x} className="radio-firmness__seam" x1={x} y1={S + 7} x2={x} y2={104} />
))}
<ellipse className="radio-firmness__shadow" ref={shadow} cx={110} cy={S - 1} rx={12} ry={2.5} style={{ opacity: REST.shadow * 0.5 }} />
<circle className="radio-firmness__ball" ref={ball} cx={110} cy={REST.cy} r={R} />
</svg>
<span className="radio-firmness__read" aria-hidden="true">
{OPTIONS.map((o) => (
<span key={o.value}>{o.note}</span>
))}
</span>
<div className="radio-firmness__opts" role="radiogroup" aria-label="Mattress">
{OPTIONS.map((o, i) => (
<label key={o.value} className="radio-firmness__opt">
<input className="radio-firmness__input" type="radio" name={name} value={o.value} checked={pick === i} onChange={() => setPick(i)} />
{o.label}
</label>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initRadioFirmness(el) on the mounted element instead.
<div class="radio-firmness">
<svg class="radio-firmness__stage" viewBox="0 0 220 112" aria-hidden="true">
<path class="radio-firmness__bed" d="M20 80L91.5 80Q110 83.1 128.5 80L200 80V104Q200 108 196 108H24Q20 108 20 104Z"></path>
<line class="radio-firmness__seam" x1="56" y1="87" x2="56" y2="104"></line>
<line class="radio-firmness__seam" x1="83" y1="87" x2="83" y2="104"></line>
<line class="radio-firmness__seam" x1="137" y1="87" x2="137" y2="104"></line>
<line class="radio-firmness__seam" x1="164" y1="87" x2="164" y2="104"></line>
<ellipse class="radio-firmness__shadow" cx="110" cy="79" rx="12" ry="2.5" style="opacity:0.511"></ellipse>
<circle class="radio-firmness__ball" cx="110" cy="70.6" r="11"></circle>
</svg>
<span class="radio-firmness__read" aria-hidden="true">
<span>Sinks in deep and settles slowly</span>
<span>Gives a little, then holds</span>
<span>Barely gives, and bounces</span>
</span>
<div class="radio-firmness__opts" role="radiogroup" aria-label="Mattress">
<label class="radio-firmness__opt">
<input class="radio-firmness__input" type="radio" name="radio-firmness" value="soft"/>
Soft
</label>
<label class="radio-firmness__opt">
<input class="radio-firmness__input" type="radio" name="radio-firmness" checked="" value="medium"/>
Medium
</label>
<label class="radio-firmness__opt">
<input class="radio-firmness__input" type="radio" name="radio-firmness" value="firm"/>
Firm
</label>
</div>
</div>.radio-firmness { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-firmness__stage { display: block; width: 220px; height: 112px; overflow: visible; }
.radio-firmness__bed { fill: light-dark(#dbdde1, #2e3035); }
.radio-firmness__seam { stroke: light-dark(#ebedf0, #232529); stroke-width: 1.5; stroke-dasharray: 3 3; }
.radio-firmness__ball { fill: light-dark(#42454c, #e5e6e9); }
.radio-firmness__shadow { fill: light-dark(#fbfcfd, #121417); }
.radio-firmness__read { display: grid; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.radio-firmness__read > * { grid-area: 1 / 1; text-align: center; opacity: 0; transform: translateY(4px); transition: opacity 0.25s, transform 0.3s; }
.radio-firmness:has(.radio-firmness__opt:nth-child(1) input:checked) .radio-firmness__read > :nth-child(1),
.radio-firmness:has(.radio-firmness__opt:nth-child(2) input:checked) .radio-firmness__read > :nth-child(2),
.radio-firmness:has(.radio-firmness__opt:nth-child(3) input:checked) .radio-firmness__read > :nth-child(3) { opacity: 1; transform: none; }
.radio-firmness__opts { display: flex; gap: 3px; padding: 4px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-firmness__opt { position: relative; flex: 1; padding: 7px 12px; border-radius: 8px; color: light-dark(#5f626a, #a7abb2); font-size: 12.5px; font-weight: 500; text-align: center; white-space: nowrap; cursor: pointer; transition: background-color 0.25s, color 0.25s; }
.radio-firmness__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-firmness__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-firmness__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-firmness__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
.radio-firmness__read > * { transition: none; }
}function initRadioFirmness(root) {
// Spring stiffness and damping per surface (per unit mass), and how widely the dip spreads.
var SURFACES = { soft: { k: 260, c: 9, spread: 2.2 }, medium: { k: 900, c: 14, spread: 1.6 }, firm: { k: 4000, c: 22, spread: 1.2 } };
var G = 1400;
var S = 80;
var R = 11;
var DROP = 64;
var bed = root.querySelector('.radio-firmness__bed');
var ball = root.querySelector('.radio-firmness__ball');
var shadow = root.querySelector('.radio-firmness__shadow');
var raf = 0;
// The mattress top dipped under the ball, for a ball height h above the surface.
function draw(h, spread) {
var d = Math.max(0, -h);
var w = 16 + d * spread;
bed.setAttribute('d', 'M20 ' + S + 'L' + (110 - w).toFixed(1) + ' ' + S + 'Q110 ' + (S + 2 * d).toFixed(1) + ' ' + (110 + w).toFixed(1) + ' ' + S + 'L200 ' + S + 'V104Q200 108 196 108H24Q20 108 20 104Z');
ball.setAttribute('cy', (S - h - R).toFixed(1));
shadow.style.opacity = String(Math.max(0, 1 - h / 70) * 0.5);
}
// Each pick drops the ball onto the new surface. In the air only gravity acts; once it presses
// in, the surface pushes back like a damped spring, so a soft one swallows the ball and a firm
// one throws it back up.
function onChange(e) {
var o = SURFACES[e.target.value];
var rest = -G / o.k;
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return draw(rest, o.spread);
var h = DROP;
var v = 0;
var last = performance.now();
var t0 = last;
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
for (var k = 0; k < 8; k++) {
var a = -G + (h < 0 ? -o.k * h - o.c * v : 0);
v += a * (dt / 8);
h += v * (dt / 8);
}
if (now - t0 > 400 && Math.abs(v) < 3 && Math.abs(h - rest) < 0.15) return draw(rest, o.spread);
draw(h, o.spread);
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
root.addEventListener('change', onChange);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.radio-firmness').forEach(initRadioFirmness);Goal: Create a "Firmness" radio.
Visual behavior: A mattress firmness picker: each pick drops a ball onto the bed, which sinks deep and settles slowly on soft, and barely gives and bounces on firm.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- Each pick drops the ball from above. In the air only gravity acts; once it presses into the surface a damped spring pushes back, with stiffness and damping per firmness, stepped eight times a frame so the firm surface stays stable.
- The mattress top is a path whose dip deepens and widens with how far the ball has sunk, and a shadow under the ball fades as it rises.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the ball rests at its depth without dropping.
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 "Firmness" radio.
Visual behavior: A mattress firmness picker: each pick drops a ball onto the bed, which sinks deep and settles slowly on soft, and barely gives and bounces on firm.
Technique:
- Native radio inputs sharing one name inside a labelled role="radiogroup", visually hidden but focusable; each option is a <label> wrapping its input, so the arrow keys and clicks both work.
- Each pick drops the ball from above. In the air only gravity acts; once it presses into the surface a damped spring pushes back, with stiffness and damping per firmness, stepped eight times a frame so the firm surface stays stable.
- The mattress top is a path whose dip deepens and widens with how far the ball has sunk, and a shadow under the ball fades as it rises.
Accessibility: A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the ball rests at its depth without dropping.
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
A mattress firmness picker: each pick drops a ball onto the bed, which sinks deep and settles slowly on soft, and barely gives and bounces on firm.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, spring
- Techniques
- raf, spring, svg
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A role="radiogroup" with an aria-label around native radio inputs sharing one name, so the arrow keys move the choice and Tab enters the group once; :focus-visible on the input outlines its option. The drawing is aria-hidden. Under prefers-reduced-motion the ball rests at its depth without dropping.