Resolution
import { useEffect, useId, useRef, useState } from 'react';
const css = `
.radio-resolution { display: grid; justify-items: center; gap: 12px; }
.radio-resolution__screen {
position: relative;
width: 192px;
height: 108px;
overflow: hidden;
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.radio-resolution__screen canvas { display: block; width: 192px; height: 108px; }
.radio-resolution__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.radio-resolution__probe i:nth-child(1) { color: light-dark(#f3f4f7, #1a1c1f); }
.radio-resolution__probe i:nth-child(2) { color: light-dark(#ebedf0, #232529); }
.radio-resolution__probe i:nth-child(3) { color: light-dark(#dbdde1, #2e3035); }
.radio-resolution__probe i:nth-child(4) { color: light-dark(#cbced3, #393c41); }
.radio-resolution__probe i:nth-child(5) { color: light-dark(#6d7179, #7d8087); }
.radio-resolution__probe i:nth-child(6) { color: light-dark(#50535b, #c6c8cd); }
.radio-resolution__probe i:nth-child(7) { color: light-dark(#373a40, #f1f2f3); }
.radio-resolution__opts { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-resolution__opt {
position: relative;
padding: 5px 12px;
border-radius: 7px;
color: light-dark(#5f626a, #a7abb2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s, background-color 0.2s;
}
.radio-resolution__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-resolution__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.radio-resolution__opt:has(.radio-resolution__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.radio-resolution__opt:has(.radio-resolution__input:checked) { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); }
`;
const W = 192;
const H = 108;
const QUALITIES = [
{ value: '240', label: '240p', cols: 24 },
{ value: '720', label: '720p', cols: 64 },
{ value: '4k', label: '4K', cols: 192 },
];
/** The scene, as a palette index (0 darkest … 6 lightest) at u, v in 0…1. */
function shade(u: number, v: number) {
const sun = Math.hypot((u - 0.7) * 1.78, v - 0.3);
if (sun < 0.1) return 6;
const ridge = 0.6 - 0.24 * Math.max(0, 1 - Math.abs(u - 0.28) / 0.3) - 0.16 * Math.max(0, 1 - Math.abs(u - 0.62) / 0.24);
if (v > 0.8) return Math.abs(u - 0.7) < 0.035 && v < 0.95 ? 5 : 2;
const hill = 0.74 - 0.05 * Math.sin(u * 9);
if (v > hill) return 4;
if (v > ridge) return v < ridge + 0.05 && Math.abs(u - 0.28) < 0.06 ? 5 : 3;
if (sun < 0.16) return 4;
return v < 0.3 ? 0 : 1;
}
export function ResolutionRadio() {
const name = useId();
const canvas = useRef<HTMLCanvasElement>(null);
const probe = useRef<HTMLSpanElement>(null);
const shown = useRef(QUALITIES[0].cols);
const [cols, setCols] = useState(QUALITIES[0].cols);
useEffect(() => {
const el = canvas.current!;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = W * dpr;
el.height = H * dpr;
const ctx = el.getContext('2d')!;
const draw = (c: number) => {
const palette = [...probe.current!.children].map((x) => getComputedStyle(x).color);
const rows = Math.round((c * H) / W);
const cw = el.width / c;
const ch = el.height / rows;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < c; x++) {
ctx.fillStyle = palette[shade((x + 0.5) / c, (y + 0.5) / rows)];
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw), Math.ceil(ch));
}
}
};
// Step through the resolutions in between, so it sharpens (or breaks up) progressively.
const from = shown.current;
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
const steps = still || from === cols ? [cols] : Array.from({ length: 6 }, (_, i) => Math.round(from * (cols / from) ** ((i + 1) / 6)));
let i = 0;
let timer = 0;
const next = () => {
draw(steps[i]);
shown.current = steps[i];
if (++i < steps.length) timer = window.setTimeout(next, 70);
};
next();
return () => clearTimeout(timer);
}, [cols]);
return (
<>
<style>
{css}
</style>
<div className="radio-resolution">
<span className="radio-resolution__screen" aria-hidden="true">
<span ref={probe} className="radio-resolution__probe">
<i />
<i />
<i />
<i />
<i />
<i />
<i />
</span>
<canvas ref={canvas} />
</span>
<div
className="radio-resolution__opts"
role="radiogroup"
aria-label="Quality"
onChange={(e) => setCols(QUALITIES.find((q) => q.value === (e.target as HTMLInputElement).value)!.cols)}
>
{QUALITIES.map((q, i) => (
<label key={q.value} className="radio-resolution__opt">
<input className="radio-resolution__input" type="radio" name={name} value={q.value} defaultChecked={i === 0} />
{q.label}
</label>
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initRadioResolution(el) on the mounted element instead.
<div class="radio-resolution">
<span class="radio-resolution__screen" aria-hidden="true">
<span class="radio-resolution__probe">
<i></i>
<i></i>
<i></i>
<i></i>
<i></i>
<i></i>
<i></i>
</span>
<canvas></canvas>
</span>
<div class="radio-resolution__opts" role="radiogroup" aria-label="Quality">
<label class="radio-resolution__opt"><input class="radio-resolution__input" type="radio" name="radio-resolution" checked="" value="240"/>240p</label>
<label class="radio-resolution__opt"><input class="radio-resolution__input" type="radio" name="radio-resolution" value="720"/>720p</label>
<label class="radio-resolution__opt"><input class="radio-resolution__input" type="radio" name="radio-resolution" value="4k"/>4K</label>
</div>
</div>.radio-resolution { display: grid; justify-items: center; gap: 12px; }
.radio-resolution__screen {
position: relative;
width: 192px;
height: 108px;
overflow: hidden;
border-radius: 8px;
background: light-dark(#ebedf0, #232529);
box-shadow: 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.radio-resolution__screen canvas { display: block; width: 192px; height: 108px; }
.radio-resolution__probe { position: absolute; width: 0; height: 0; overflow: hidden; }
.radio-resolution__probe i:nth-child(1) { color: light-dark(#f3f4f7, #1a1c1f); }
.radio-resolution__probe i:nth-child(2) { color: light-dark(#ebedf0, #232529); }
.radio-resolution__probe i:nth-child(3) { color: light-dark(#dbdde1, #2e3035); }
.radio-resolution__probe i:nth-child(4) { color: light-dark(#cbced3, #393c41); }
.radio-resolution__probe i:nth-child(5) { color: light-dark(#6d7179, #7d8087); }
.radio-resolution__probe i:nth-child(6) { color: light-dark(#50535b, #c6c8cd); }
.radio-resolution__probe i:nth-child(7) { color: light-dark(#373a40, #f1f2f3); }
.radio-resolution__opts { display: flex; gap: 4px; padding: 4px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); }
.radio-resolution__opt {
position: relative;
padding: 5px 12px;
border-radius: 7px;
color: light-dark(#5f626a, #a7abb2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s, background-color 0.2s;
}
.radio-resolution__opt:hover { color: light-dark(#42454c, #e5e6e9); }
.radio-resolution__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.radio-resolution__opt:has(.radio-resolution__input:focus-visible) { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.radio-resolution__opt:has(.radio-resolution__input:checked) { background: light-dark(#42454c, #e5e6e9); color: light-dark(#fbfcfd, #121417); }function initRadioResolution(root) {
var canvas = root.querySelector('canvas');
var probe = root.querySelector('.radio-resolution__probe');
if (!canvas || !probe) return;
var W = 192;
var H = 108;
var COLS = { '240': 24, '720': 64, '4k': 192 };
var dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = W * dpr;
canvas.height = H * dpr;
var ctx = canvas.getContext('2d');
var timer = 0;
// The scene, as a palette index (0 darkest … 6 lightest) at u, v in 0…1.
function shade(u, v) {
var sun = Math.hypot((u - 0.7) * 1.78, v - 0.3);
if (sun < 0.1) return 6;
var ridge = 0.6 - 0.24 * Math.max(0, 1 - Math.abs(u - 0.28) / 0.3) - 0.16 * Math.max(0, 1 - Math.abs(u - 0.62) / 0.24);
if (v > 0.8) return Math.abs(u - 0.7) < 0.035 && v < 0.95 ? 5 : 2;
var hill = 0.74 - 0.05 * Math.sin(u * 9);
if (v > hill) return 4;
if (v > ridge) return v < ridge + 0.05 && Math.abs(u - 0.28) < 0.06 ? 5 : 3;
if (sun < 0.16) return 4;
return v < 0.3 ? 0 : 1;
}
function draw(cols) {
// Read the colours from hidden swatches, so the picture follows the page theme.
var palette = [];
for (var i = 0; i < probe.children.length; i++) palette.push(getComputedStyle(probe.children[i]).color);
var rows = Math.round((cols * H) / W);
var cw = canvas.width / cols;
var ch = canvas.height / rows;
for (var y = 0; y < rows; y++) {
for (var x = 0; x < cols; x++) {
ctx.fillStyle = palette[shade((x + 0.5) / cols, (y + 0.5) / rows)];
ctx.fillRect(Math.floor(x * cw), Math.floor(y * ch), Math.ceil(cw), Math.ceil(ch));
}
}
}
var checked = root.querySelector('input:checked');
var shown = COLS[checked ? checked.value : '240'];
draw(shown);
// Step through the resolutions in between, so it sharpens (or breaks up) progressively.
function show(cols) {
clearTimeout(timer);
var from = shown;
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var steps = [];
if (still || from === cols) steps.push(cols);
else for (var i = 0; i < 6; i++) steps.push(Math.round(from * Math.pow(cols / from, (i + 1) / 6)));
var n = 0;
(function next() {
draw(steps[n]);
shown = steps[n];
if (++n < steps.length) timer = setTimeout(next, 70);
})();
}
function onChange(e) {
if (COLS[e.target.value]) show(COLS[e.target.value]);
}
root.addEventListener('change', onChange);
return function () {
clearTimeout(timer);
root.removeEventListener('change', onChange);
};
}
document.querySelectorAll('.radio-resolution').forEach(initRadioResolution);Goal: Create a "Resolution" radio.
Visual behavior: A video quality picker: choose 240p, 720p or 4K and the picture breaks into blocks or sharpens, stepping through the resolutions in between.
Technique:
- Real native radios (visually hidden: absolute, 1x1, opacity 0) inside <label>s; the scene is aria-hidden and every state is read from the checked input with :has().
- A small landscape is described as a function of position and drawn into a canvas as a grid of cells; the colours are read from hidden swatches, so the canvas follows the page theme.
- On a change it draws six grids between the old and the new size, 70ms apart.
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. Under prefers-reduced-motion it jumps straight to the new quality.
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 "Resolution" radio.
Visual behavior: A video quality picker: choose 240p, 720p or 4K and the picture breaks into blocks or sharpens, stepping through the resolutions in between.
Technique:
- Real native radios (visually hidden: absolute, 1x1, opacity 0) inside <label>s; the scene is aria-hidden and every state is read from the checked input with :has().
- A small landscape is described as a function of position and drawn into a canvas as a grid of cells; the colours are read from hidden swatches, so the canvas follows the page theme.
- On a change it draws six grids between the old and the new size, 70ms apart.
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. Under prefers-reduced-motion it jumps straight to the new quality.
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 video quality picker: choose 240p, 720p or 4K and the picture breaks into blocks or sharpens, stepping through the resolutions in between.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change
- Techniques
- interval, css variables
- 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. Under prefers-reduced-motion it jumps straight to the new quality.