Halftone
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const COLS = 34;
const ROWS = 6;
const PITCH = 7;
const TOP = 8;
const BAND = ROWS * PITCH;
const LEFT = (W - (COLS - 1) * PITCH - PITCH / 2) / 2;
const css = `
.slider-halftone { position: relative; width: ${W}px; height: ${BAND + TOP * 2}px; }
.slider-halftone__svg { display: block; width: ${W}px; height: ${BAND + TOP * 2}px; overflow: visible; }
.slider-halftone__ink { fill: light-dark(#42454c, #e5e6e9); }
.slider-halftone__paper { fill: light-dark(#cbced3, #393c41); }
.slider-halftone__thumb {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 2px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-halftone:has(.slider-halftone__input:active) .slider-halftone__thumb { transform: scale(1.25, 1.06); }
.slider-halftone__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-halftone:has(.slider-halftone__input:focus-visible) .slider-halftone__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-halftone__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-halftone__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 58px; }
.slider-halftone__input::-moz-range-thumb { width: 12px; height: 58px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-halftone__thumb { transition: none; }
}
`;
const smooth = (x: number) => {
const c = Math.min(1, Math.max(0, x));
return c * c * (3 - 2 * c);
};
/** Follows the value on a soft spring, so a click far along the track washes the ink across. */
function useSpring(target: number) {
const [x, setX] = useState(target);
const s = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const st = s.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
st.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
st.v += (-200 * (st.x - target) - 26 * st.v) * dt;
st.x += st.v * dt;
if (Math.abs(st.x - target) < 0.02 && Math.abs(st.v) < 0.05) {
st.x = target;
st.v = 0;
setX(target);
return;
}
setX(st.x);
st.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(st.raf);
st.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(st.raf);
}, [target]);
return x;
}
export function HalftoneSlider() {
const [v, setV] = useState(55);
const shown = useSpring(v);
const clip = `slider-halftone-${useId().replace(/[^a-zA-Z0-9]/g, '')}`;
const t = 6 + (shown / 100) * (W - 12);
const dots = [];
for (let j = 0; j < ROWS; j++) {
// One extra column each side, cut by the clip, so the offset rows still reach the ends.
for (let i = -1; i <= COLS; i++) {
const x = LEFT + i * PITCH + (j % 2) * (PITCH / 2);
const behind = t - x;
// Far behind the thumb the dots overlap into solid ink; at the edge they are fine points.
const r = behind > 0 ? 1.2 + 3.5 * smooth(behind / 72) : 1.1;
dots.push(<circle key={`${i}-${j}`} className={behind > 0 ? 'slider-halftone__ink' : 'slider-halftone__paper'} cx={x} cy={TOP + PITCH / 2 + j * PITCH} r={r} />);
}
}
return (
<>
<style>
{css}
</style>
<div className="slider-halftone">
<svg className="slider-halftone__svg" aria-hidden="true">
<defs>
<clipPath id={clip}>
<rect x={0} y={TOP} width={W} height={BAND} rx={9} />
</clipPath>
</defs>
<g clipPath={`url(#${clip})`}>{dots}</g>
<rect className="slider-halftone__thumb" x={t - 3} y={TOP - 6} width={6} height={BAND + 12} rx={3} />
<rect className="slider-halftone__ring" x={t - 7} y={TOP - 10} width={14} height={BAND + 20} rx={7} />
</svg>
<input
className="slider-halftone__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Ink coverage"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderHalftone(el) on the mounted element instead.
<div class="slider-halftone">
<svg class="slider-halftone__svg" aria-hidden="true">
<defs>
<clipPath id="slider-halftone-clip">
<rect x="0" y="8" width="240" height="42" rx="9"></rect>
</clipPath>
</defs>
<g clip-path="url(#slider-halftone-clip)">
<circle class="slider-halftone__ink" cx="-4.25" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="2.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="9.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="16.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="23.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="30.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="37.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="44.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="51.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="58.75" cy="11.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="65.75" cy="11.5" r="4.623"></circle>
<circle class="slider-halftone__ink" cx="72.75" cy="11.5" r="4.384"></circle>
<circle class="slider-halftone__ink" cx="79.75" cy="11.5" r="4.019"></circle>
<circle class="slider-halftone__ink" cx="86.75" cy="11.5" r="3.569"></circle>
<circle class="slider-halftone__ink" cx="93.75" cy="11.5" r="3.07"></circle>
<circle class="slider-halftone__ink" cx="100.75" cy="11.5" r="2.563"></circle>
<circle class="slider-halftone__ink" cx="107.75" cy="11.5" r="2.085"></circle>
<circle class="slider-halftone__ink" cx="114.75" cy="11.5" r="1.675"></circle>
<circle class="slider-halftone__ink" cx="121.75" cy="11.5" r="1.372"></circle>
<circle class="slider-halftone__ink" cx="128.75" cy="11.5" r="1.214"></circle>
<circle class="slider-halftone__paper" cx="135.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="142.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="149.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="156.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="163.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="170.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="177.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="184.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="191.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="198.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="205.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="212.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="219.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="226.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="233.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="240.75" cy="11.5" r="1.1"></circle>
<circle class="slider-halftone__ink" cx="-0.75" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="6.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="13.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="20.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="27.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="34.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="41.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="48.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="55.25" cy="18.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="62.25" cy="18.5" r="4.684"></circle>
<circle class="slider-halftone__ink" cx="69.25" cy="18.5" r="4.521"></circle>
<circle class="slider-halftone__ink" cx="76.25" cy="18.5" r="4.215"></circle>
<circle class="slider-halftone__ink" cx="83.25" cy="18.5" r="3.802"></circle>
<circle class="slider-halftone__ink" cx="90.25" cy="18.5" r="3.323"></circle>
<circle class="slider-halftone__ink" cx="97.25" cy="18.5" r="2.815"></circle>
<circle class="slider-halftone__ink" cx="104.25" cy="18.5" r="2.318"></circle>
<circle class="slider-halftone__ink" cx="111.25" cy="18.5" r="1.869"></circle>
<circle class="slider-halftone__ink" cx="118.25" cy="18.5" r="1.508"></circle>
<circle class="slider-halftone__ink" cx="125.25" cy="18.5" r="1.272"></circle>
<circle class="slider-halftone__paper" cx="132.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="139.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="146.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="153.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="160.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="167.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="174.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="181.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="188.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="195.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="202.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="209.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="216.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="223.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="230.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="237.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="244.25" cy="18.5" r="1.1"></circle>
<circle class="slider-halftone__ink" cx="-4.25" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="2.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="9.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="16.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="23.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="30.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="37.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="44.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="51.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="58.75" cy="25.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="65.75" cy="25.5" r="4.623"></circle>
<circle class="slider-halftone__ink" cx="72.75" cy="25.5" r="4.384"></circle>
<circle class="slider-halftone__ink" cx="79.75" cy="25.5" r="4.019"></circle>
<circle class="slider-halftone__ink" cx="86.75" cy="25.5" r="3.569"></circle>
<circle class="slider-halftone__ink" cx="93.75" cy="25.5" r="3.07"></circle>
<circle class="slider-halftone__ink" cx="100.75" cy="25.5" r="2.563"></circle>
<circle class="slider-halftone__ink" cx="107.75" cy="25.5" r="2.085"></circle>
<circle class="slider-halftone__ink" cx="114.75" cy="25.5" r="1.675"></circle>
<circle class="slider-halftone__ink" cx="121.75" cy="25.5" r="1.372"></circle>
<circle class="slider-halftone__ink" cx="128.75" cy="25.5" r="1.214"></circle>
<circle class="slider-halftone__paper" cx="135.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="142.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="149.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="156.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="163.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="170.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="177.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="184.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="191.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="198.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="205.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="212.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="219.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="226.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="233.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="240.75" cy="25.5" r="1.1"></circle>
<circle class="slider-halftone__ink" cx="-0.75" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="6.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="13.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="20.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="27.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="34.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="41.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="48.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="55.25" cy="32.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="62.25" cy="32.5" r="4.684"></circle>
<circle class="slider-halftone__ink" cx="69.25" cy="32.5" r="4.521"></circle>
<circle class="slider-halftone__ink" cx="76.25" cy="32.5" r="4.215"></circle>
<circle class="slider-halftone__ink" cx="83.25" cy="32.5" r="3.802"></circle>
<circle class="slider-halftone__ink" cx="90.25" cy="32.5" r="3.323"></circle>
<circle class="slider-halftone__ink" cx="97.25" cy="32.5" r="2.815"></circle>
<circle class="slider-halftone__ink" cx="104.25" cy="32.5" r="2.318"></circle>
<circle class="slider-halftone__ink" cx="111.25" cy="32.5" r="1.869"></circle>
<circle class="slider-halftone__ink" cx="118.25" cy="32.5" r="1.508"></circle>
<circle class="slider-halftone__ink" cx="125.25" cy="32.5" r="1.272"></circle>
<circle class="slider-halftone__paper" cx="132.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="139.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="146.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="153.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="160.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="167.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="174.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="181.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="188.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="195.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="202.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="209.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="216.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="223.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="230.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="237.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="244.25" cy="32.5" r="1.1"></circle>
<circle class="slider-halftone__ink" cx="-4.25" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="2.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="9.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="16.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="23.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="30.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="37.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="44.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="51.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="58.75" cy="39.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="65.75" cy="39.5" r="4.623"></circle>
<circle class="slider-halftone__ink" cx="72.75" cy="39.5" r="4.384"></circle>
<circle class="slider-halftone__ink" cx="79.75" cy="39.5" r="4.019"></circle>
<circle class="slider-halftone__ink" cx="86.75" cy="39.5" r="3.569"></circle>
<circle class="slider-halftone__ink" cx="93.75" cy="39.5" r="3.07"></circle>
<circle class="slider-halftone__ink" cx="100.75" cy="39.5" r="2.563"></circle>
<circle class="slider-halftone__ink" cx="107.75" cy="39.5" r="2.085"></circle>
<circle class="slider-halftone__ink" cx="114.75" cy="39.5" r="1.675"></circle>
<circle class="slider-halftone__ink" cx="121.75" cy="39.5" r="1.372"></circle>
<circle class="slider-halftone__ink" cx="128.75" cy="39.5" r="1.214"></circle>
<circle class="slider-halftone__paper" cx="135.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="142.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="149.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="156.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="163.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="170.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="177.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="184.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="191.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="198.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="205.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="212.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="219.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="226.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="233.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="240.75" cy="39.5" r="1.1"></circle>
<circle class="slider-halftone__ink" cx="-0.75" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="6.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="13.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="20.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="27.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="34.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="41.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="48.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="55.25" cy="46.5" r="4.7"></circle>
<circle class="slider-halftone__ink" cx="62.25" cy="46.5" r="4.684"></circle>
<circle class="slider-halftone__ink" cx="69.25" cy="46.5" r="4.521"></circle>
<circle class="slider-halftone__ink" cx="76.25" cy="46.5" r="4.215"></circle>
<circle class="slider-halftone__ink" cx="83.25" cy="46.5" r="3.802"></circle>
<circle class="slider-halftone__ink" cx="90.25" cy="46.5" r="3.323"></circle>
<circle class="slider-halftone__ink" cx="97.25" cy="46.5" r="2.815"></circle>
<circle class="slider-halftone__ink" cx="104.25" cy="46.5" r="2.318"></circle>
<circle class="slider-halftone__ink" cx="111.25" cy="46.5" r="1.869"></circle>
<circle class="slider-halftone__ink" cx="118.25" cy="46.5" r="1.508"></circle>
<circle class="slider-halftone__ink" cx="125.25" cy="46.5" r="1.272"></circle>
<circle class="slider-halftone__paper" cx="132.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="139.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="146.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="153.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="160.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="167.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="174.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="181.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="188.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="195.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="202.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="209.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="216.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="223.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="230.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="237.25" cy="46.5" r="1.1"></circle>
<circle class="slider-halftone__paper" cx="244.25" cy="46.5" r="1.1"></circle>
</g>
<rect class="slider-halftone__thumb" x="128.4" y="2" width="6" height="54" rx="3"></rect>
<rect class="slider-halftone__ring" x="124.4" y="-2" width="14" height="62" rx="7"></rect>
</svg>
<input class="slider-halftone__input" type="range" min="0" max="100" aria-label="Ink coverage" aria-valuetext="55%" value="55"/>
</div>.slider-halftone { position: relative; width: 240px; height: 58px; }
.slider-halftone__svg { display: block; width: 240px; height: 58px; overflow: visible; }
.slider-halftone__ink { fill: light-dark(#42454c, #e5e6e9); }
.slider-halftone__paper { fill: light-dark(#cbced3, #393c41); }
.slider-halftone__thumb {
fill: light-dark(#373a40, #f1f2f3);
filter: drop-shadow(0 2px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)));
transform-box: fill-box;
transform-origin: center;
transition: transform 0.35s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-halftone:has(.slider-halftone__input:active) .slider-halftone__thumb { transform: scale(1.25, 1.06); }
.slider-halftone__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-halftone:has(.slider-halftone__input:focus-visible) .slider-halftone__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-halftone__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-halftone__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 58px; }
.slider-halftone__input::-moz-range-thumb { width: 12px; height: 58px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-halftone__thumb { transition: none; }
}function initSliderHalftone(root) {
var W = 240;
var input = root.querySelector('.slider-halftone__input');
var dots = root.querySelectorAll('circle');
var thumb = root.querySelector('.slider-halftone__thumb');
var ring = root.querySelector('.slider-halftone__ring');
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var s = { x: +input.value, v: 0, raf: 0 };
var xs = Array.prototype.map.call(dots, function (d) { return +d.getAttribute('cx'); });
function smooth(x) {
var c = Math.min(1, Math.max(0, x));
return c * c * (3 - 2 * c);
}
function draw(shown) {
var t = 6 + (shown / 100) * (W - 12);
dots.forEach(function (dot, i) {
var behind = t - xs[i];
// Far behind the thumb the dots overlap into solid ink; at the edge they are fine points.
dot.setAttribute('r', behind > 0 ? 1.2 + 3.5 * smooth(behind / 72) : 1.1);
dot.setAttribute('class', behind > 0 ? 'slider-halftone__ink' : 'slider-halftone__paper');
});
thumb.setAttribute('x', t - 3);
ring.setAttribute('x', t - 7);
}
// Follows the value on a soft spring, so a click far along the track washes the ink across.
function update() {
var target = +input.value;
input.setAttribute('aria-valuetext', target + '%');
cancelAnimationFrame(s.raf);
if (still) {
s.x = target;
draw(target);
return;
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
s.v += (-200 * (s.x - target) - 26 * s.v) * dt;
s.x += s.v * dt;
if (Math.abs(s.x - target) < 0.02 && Math.abs(s.v) < 0.05) {
s.x = target;
s.v = 0;
draw(target);
return;
}
draw(s.x);
s.raf = requestAnimationFrame(step);
}
s.raf = requestAnimationFrame(step);
}
input.addEventListener('input', update);
return function () {
cancelAnimationFrame(s.raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-halftone').forEach(initSliderHalftone);Goal: Create a "Halftone" slider.
Visual behavior: An ink coverage slider printed in halftone: dots far behind the thumb swell and fuse into solid ink, thinning to fine points at its edge, with faint dots ahead.
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.
- A grid of SVG circles in offset rows, clipped to a rounded band. Each dot's radius comes from its distance behind the thumb through a smoothstep, large enough far back that neighbours overlap into solid ink.
- The drawn value follows the input on a soft spring, so a click far along washes the ink across.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the ink moves without the spring.
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 "Halftone" slider.
Visual behavior: An ink coverage slider printed in halftone: dots far behind the thumb swell and fuse into solid ink, thinning to fine points at its edge, with faint dots ahead.
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.
- A grid of SVG circles in offset rows, clipped to a rounded band. Each dot's radius comes from its distance behind the thumb through a smoothstep, large enough far back that neighbours overlap into solid ink.
- The drawn value follows the input on a soft spring, so a click far along washes the ink across.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the ink moves without the spring.
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
An ink coverage slider printed in halftone: dots far behind the thumb swell and fuse into solid ink, thinning to fine points at its edge, with faint dots ahead.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, fill
- 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 thumb. Under prefers-reduced-motion the ink moves without the spring.