No sound, no buzzBuzzes in short burstsRings out loud
FPS
12 frames a second. Steppy, like stop motion.
React + Tailwind
import { useEffect, useId, useRef, useState } from 'react';
const OPTIONS = [
{ value: 12, note: 'Steppy, like stop motion' },
{ value: 24, note: 'Smooth enough, like film' },
{ value: 60, note: 'Fluid, like a phone screen' },
];
const PERIOD = 2.4;
// Where the ball is at time t: across and back once a period, bouncing twice each way.
function pos(t: number) {
const u = (((t % PERIOD) + PERIOD) % PERIOD) / PERIOD;
return { x: 24 + 172 * ((1 - Math.cos(2 * Math.PI * u)) / 2), y: 76 - 50 * Math.abs(Math.sin(4 * Math.PI * u)) };
}
const START = pos(0);
const css = `
.radio-fps { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-fps__stage { display: block; width: 220px; height: 92px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-fps__ground { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; }
.radio-fps__ball { fill: light-dark(#373a40, #f1f2f3); }
.radio-fps__ghost { fill: light-dark(#5f626a, #a7abb2); }
.radio-fps__dot { fill: light-dark(#6d7179, #7d8087); }
.radio-fps__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; text-align: center; }
.radio-fps__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-fps__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-fps__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-fps__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-fps__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-fps__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }
`;
export function FpsRadio() {
const name = useId();
const [fps, setFps] = useState(12);
const [still, setStill] = useState(false);
const stage = useRef<SVGSVGElement>(null);
const ball = useRef<SVGCircleElement>(null);
const ghosts = useRef<(SVGCircleElement | null)[]>([]);
// The ball only moves on frame boundaries: the clock runs smoothly, but its position is read at
// 1/fps steps. The last three frames stay faintly behind it, so the spacing shows.
useEffect(() => {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setStill(true);
let raf = 0;
let last = -1;
const t0 = performance.now();
const step = (now: number) => {
const f = Math.floor(((now - t0) / 1000) * fps);
if (f !== last) {
last = f;
const p = pos(f / fps);
ball.current!.setAttribute('cx', p.x.toFixed(1));
ball.current!.setAttribute('cy', p.y.toFixed(1));
ghosts.current.forEach((g, k) => {
const q = pos((f - k - 1) / fps);
g!.setAttribute('cx', q.x.toFixed(1));
g!.setAttribute('cy', q.y.toFixed(1));
});
}
raf = requestAnimationFrame(step);
};
// Only run while the stage is on screen.
const io = new IntersectionObserver(([e]) => {
cancelAnimationFrame(raf);
if (e.isIntersecting) raf = requestAnimationFrame(step);
});
io.observe(stage.current!);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}, [fps]);
// Without motion: every frame of one pass as a dot, so 12 fps leaves wide gaps and 60 fps a near line.
const dots = still ? Array.from({ length: Math.round(PERIOD * fps) }, (_, k) => pos(k / fps)) : [];
return (
<>
<style>
{css}
</style>
<div className="radio-fps">
<svg className="radio-fps__stage" ref={stage} viewBox="0 0 220 92" aria-hidden="true">
<line className="radio-fps__ground" x1={14} y1={84} x2={206} y2={84} />
{dots.map((p, k) => (
<circle key={k} className="radio-fps__dot" cx={p.x} cy={p.y} r={1.6} />
))}
{!still &&
[0.45, 0.25, 0.12].map((o, k) => (
<circle
key={k}
className="radio-fps__ghost"
ref={(el) => {
ghosts.current[k] = el;
}}
cx={START.x}
cy={START.y}
r={7}
opacity={o}
/>
))}
<circle className="radio-fps__ball" ref={ball} cx={START.x} cy={START.y} r={7} />
</svg>
<span className="radio-fps__read" aria-live="polite">
{fps} frames a second. {OPTIONS.find((o) => o.value === fps)!.note}.
</span>
<div className="radio-fps__opts" role="radiogroup" aria-label="Frame rate">
{OPTIONS.map((o) => (
<label key={o.value} className="radio-fps__opt">
<input className="radio-fps__input" type="radio" name={name} value={o.value} checked={fps === o.value} onChange={() => setFps(o.value)} />
{o.value} fps
</label>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initRadioFps(el) on the mounted element instead.
HTML
<div class="radio-fps">
<svg class="radio-fps__stage" viewBox="0 0 220 92" aria-hidden="true">
<line class="radio-fps__ground" x1="14" y1="84" x2="206" y2="84"></line>
<circle class="radio-fps__ghost" cx="24" cy="76" r="7" opacity="0.45"></circle>
<circle class="radio-fps__ghost" cx="24" cy="76" r="7" opacity="0.25"></circle>
<circle class="radio-fps__ghost" cx="24" cy="76" r="7" opacity="0.12"></circle>
<circle class="radio-fps__ball" cx="24" cy="76" r="7"></circle>
</svg>
<span class="radio-fps__read" aria-live="polite">12 frames a second. Steppy, like stop motion.</span>
<div class="radio-fps__opts" role="radiogroup" aria-label="Frame rate">
<label class="radio-fps__opt">
<input class="radio-fps__input" type="radio" name="radio-fps" checked="" value="12"/>
12 fps
</label>
<label class="radio-fps__opt">
<input class="radio-fps__input" type="radio" name="radio-fps" value="24"/>
24 fps
</label>
<label class="radio-fps__opt">
<input class="radio-fps__input" type="radio" name="radio-fps" value="60"/>
60 fps
</label>
</div>
</div>CSS
.radio-fps { display: grid; justify-items: center; gap: 14px; width: 260px; }
.radio-fps__stage { display: block; width: 220px; height: 92px; border-radius: 12px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-fps__ground { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; }
.radio-fps__ball { fill: light-dark(#373a40, #f1f2f3); }
.radio-fps__ghost { fill: light-dark(#5f626a, #a7abb2); }
.radio-fps__dot { fill: light-dark(#6d7179, #7d8087); }
.radio-fps__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; text-align: center; }
.radio-fps__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-fps__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-fps__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-fps__opt:has(input:checked) { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.radio-fps__opt:has(input:focus-visible) { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.radio-fps__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }JavaScript
function initRadioFps(root) {
var NOTES = { 12: 'Steppy, like stop motion', 24: 'Smooth enough, like film', 60: 'Fluid, like a phone screen' };
var PERIOD = 2.4;
var SVG = 'http://www.w3.org/2000/svg';
var stage = root.querySelector('.radio-fps__stage');
var ball = root.querySelector('.radio-fps__ball');
var ghosts = root.querySelectorAll('.radio-fps__ghost');
var read = root.querySelector('.radio-fps__read');
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var fps = +root.querySelector('.radio-fps__input:checked').value;
var raf = 0;
var t0 = performance.now();
var last = -1;
// Where the ball is at time t: across and back once a period, bouncing twice each way.
function pos(t) {
var u = (((t % PERIOD) + PERIOD) % PERIOD) / PERIOD;
return { x: 24 + 172 * ((1 - Math.cos(2 * Math.PI * u)) / 2), y: 76 - 50 * Math.abs(Math.sin(4 * Math.PI * u)) };
}
function put(el, p) {
el.setAttribute('cx', p.x.toFixed(1));
el.setAttribute('cy', p.y.toFixed(1));
}
// The ball only moves on frame boundaries: the clock runs smoothly, but its position is read at
// 1/fps steps. The last three frames stay faintly behind it, so the spacing shows.
function step(now) {
var f = Math.floor(((now - t0) / 1000) * fps);
if (f !== last) {
last = f;
put(ball, pos(f / fps));
ghosts.forEach(function (g, k) { put(g, pos((f - k - 1) / fps)); });
}
raf = requestAnimationFrame(step);
}
// Without motion: every frame of one pass as a dot, so 12 fps leaves wide gaps and 60 fps a near line.
function dots() {
stage.querySelectorAll('.radio-fps__dot').forEach(function (d) { d.remove(); });
for (var k = 0; k < Math.round(PERIOD * fps); k++) {
var dot = document.createElementNS(SVG, 'circle');
dot.setAttribute('class', 'radio-fps__dot');
dot.setAttribute('r', '1.6');
put(dot, pos(k / fps));
stage.insertBefore(dot, ghosts[0] || ball);
}
}
function onChange(e) {
fps = +e.target.value;
read.textContent = fps + ' frames a second. ' + NOTES[fps] + '.';
if (still) return dots();
t0 = performance.now();
last = -1;
}
// Only run while the stage is on screen.
var io = new IntersectionObserver(function (entries) {
cancelAnimationFrame(raf);
if (entries[0].isIntersecting) raf = requestAnimationFrame(step);
});
if (still) {
ghosts.forEach(function (g) { g.remove(); });
dots();
} else io.observe(stage);
root.addEventListener('change', onChange);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.radio-fps').forEach(initRadioFps);Goal: Create a "FPS" radio.
Visual behavior: A frame rate picker: a bouncing ball is only drawn on frame boundaries, with its last few frames left faintly behind, so 12, 24 and 60 fps look steppy, filmic or fluid.
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.
- The clock runs smoothly in requestAnimationFrame but the ball's position is only read at whole frames of the chosen rate, so 12 fps visibly steps. Three fading copies show the last frames, so the spacing reads.
- An IntersectionObserver runs the loop only while it is on screen.
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. The chosen rate is described in a polite live region. Under prefers-reduced-motion the ball stays still and every frame of one pass is drawn as a dot instead.
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 "FPS" radio.
Visual behavior: A frame rate picker: a bouncing ball is only drawn on frame boundaries, with its last few frames left faintly behind, so 12, 24 and 60 fps look steppy, filmic or fluid.
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.
- The clock runs smoothly in requestAnimationFrame but the ball's position is only read at whole frames of the chosen rate, so 12 fps visibly steps. Three fading copies show the last frames, so the spacing reads.
- An IntersectionObserver runs the loop only while it is on screen.
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. The chosen rate is described in a polite live region. Under prefers-reduced-motion the ball stays still and every frame of one pass is drawn as a dot instead.
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 frame rate picker: a bouncing ball is only drawn on frame boundaries, with its last few frames left faintly behind, so 12, 24 and 60 fps look steppy, filmic or fluid.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, loop
- Techniques
- raf, 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. The chosen rate is described in a polite live region. Under prefers-reduced-motion the ball stays still and every frame of one pass is drawn as a dot instead.