Brush
import { useId, useState } from 'react';
const W = 240;
const MID = 24;
/** One stroke from the left edge to `e`: it swells in from a point, wavers a little and ends in a round, wet tip. */
function stroke(e: number) {
const top: string[] = [];
const bot: string[] = [];
let w = 0;
// The brush lands at half pressure and presses down to full width over the first 30px.
const width = (x: number) => 9.5 * (0.5 + 0.5 * Math.min(1, x / 30)) * (1 + 0.06 * Math.sin(x / 15));
const y0 = MID + 0.9 * Math.sin(5 / 26);
for (let x = 5; x <= e; x += 4) {
w = width(x);
const y = MID + 0.9 * Math.sin(x / 26);
top.push(`${x.toFixed(1)},${(y - w).toFixed(2)}`);
bot.unshift(`${x.toFixed(1)},${(y + w).toFixed(2)}`);
}
const w0 = width(5);
const yEnd = MID + 0.9 * Math.sin(e / 26);
return {
d: `M${top.join('L')}L${e},${(yEnd - w).toFixed(2)}A${w} ${w} 0 0 1 ${e},${(yEnd + w).toFixed(2)}L${bot.join('L')}L5,${(y0 + w0).toFixed(2)}A${w0} ${w0} 0 0 1 5,${(y0 - w0).toFixed(2)}Z`,
w,
y: yEnd,
};
}
const css = `
.slider-brush { position: relative; width: ${W}px; height: 62px; }
.slider-brush__svg { display: block; width: ${W}px; height: 48px; overflow: visible; }
.slider-brush__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-dasharray: 1 5; stroke-linecap: round; }
.slider-brush__paint { fill: light-dark(#42454c, #e5e6e9); }
.slider-brush__fade { stop-color: light-dark(#42454c, #e5e6e9); }
.slider-brush__wet { fill: light-dark(#373a40, #f1f2f3); opacity: 0.45; transition: opacity 0.2s; }
.slider-brush:has(.slider-brush__input:active) .slider-brush__wet { opacity: 0.8; }
.slider-brush__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-brush:has(.slider-brush__input:focus-visible) .slider-brush__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-brush__value { position: absolute; left: 0; right: 0; top: 46px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-brush__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-brush__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-brush__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 48px; }
.slider-brush__input::-moz-range-thumb { width: 16px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-brush__wet { transition: none; }
}
`;
export function BrushSlider() {
const id = useId().replace(/[^\w-]/g, '');
const [v, setV] = useState(64);
const e = 12 + (v / 100) * (W - 26);
const s = stroke(e);
return (
<>
<style>
{css}
</style>
<div className="slider-brush">
<svg className="slider-brush__svg" aria-hidden="true">
<defs>
{/* Bristle streaks: noise stretched along the stroke, cut to a hard edge, roughening the outline too. */}
<filter id={`${id}-dry`} x="-5%" y="-60%" width="110%" height="220%">
<feTurbulence type="fractalNoise" baseFrequency="0.006 0.55" numOctaves="2" seed="8" result="n" />
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4.2 0 0 0 -1.55" result="m" />
<feComposite in="SourceGraphic" in2="m" operator="in" result="p" />
<feDisplacementMap in="p" in2="n" scale="3.5" xChannelSelector="R" yChannelSelector="G" />
</filter>
<filter id={`${id}-edge`} x="-5%" y="-60%" width="110%" height="220%">
<feTurbulence type="fractalNoise" baseFrequency="0.05 0.3" numOctaves="2" seed="3" result="n" />
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.5" xChannelSelector="R" yChannelSelector="G" />
</filter>
{/* Loaded where it starts, running dry by the time it reaches the thumb. */}
<linearGradient id={`${id}-load`} gradientUnits="userSpaceOnUse" x1={0} x2={e} y1={0} y2={0}>
<stop className="slider-brush__fade" offset="0" stopOpacity="1" />
<stop className="slider-brush__fade" offset="0.45" stopOpacity="0.95" />
<stop className="slider-brush__fade" offset="1" stopOpacity="0" />
</linearGradient>
</defs>
<line className="slider-brush__guide" x1={e + s.w + 6} x2={W - 1} y1={MID} y2={MID} />
<path d={s.d} fill={`url(#${id}-load)`} filter={`url(#${id}-edge)`} />
<path className="slider-brush__paint" d={s.d} filter={`url(#${id}-dry)`} />
<ellipse className="slider-brush__wet" cx={e + s.w * 0.25} cy={s.y} rx={2.6} ry={s.w * 0.8} />
<rect className="slider-brush__ring" x={e - 6} y={MID - 15} width={s.w + 14} height={30} rx={10} />
</svg>
<p className="slider-brush__value" aria-hidden="true">
<span>Flow</span>
<b>{v}%</b>
</p>
<input className="slider-brush__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Flow" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderBrush(el) on the mounted element instead.
<div class="slider-brush">
<svg class="slider-brush__svg" aria-hidden="true">
<defs>
<filter id="slider-brush-dry" x="-5%" y="-60%" width="110%" height="220%">
<feTurbulence type="fractalNoise" baseFrequency="0.006 0.55" numOctaves="2" seed="8" result="n"></feTurbulence>
<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 4.2 0 0 0 -1.55" result="m"></feColorMatrix>
<feComposite in="SourceGraphic" in2="m" operator="in" result="p"></feComposite>
<feDisplacementMap in="p" in2="n" scale="3.5" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
<filter id="slider-brush-edge" x="-5%" y="-60%" width="110%" height="220%">
<feTurbulence type="fractalNoise" baseFrequency="0.05 0.3" numOctaves="2" seed="3" result="n"></feTurbulence>
<feDisplacementMap in="SourceGraphic" in2="n" scale="2.5" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
</filter>
<linearGradient id="slider-brush-load" gradientUnits="userSpaceOnUse" x1="0" x2="148.96" y1="0" y2="0">
<stop class="slider-brush__fade" offset="0" stop-opacity="1"></stop>
<stop class="slider-brush__fade" offset="0.45" stop-opacity="0.95"></stop>
<stop class="slider-brush__fade" offset="1" stop-opacity="0"></stop>
</linearGradient>
</defs>
<line class="slider-brush__guide" x1="164.323" x2="239" y1="24" y2="24"></line>
<path d="M5.0,18.52L9.0,17.92L13.0,17.31L17.0,16.70L21.0,16.10L25.0,15.51L29.0,14.94L33.0,14.90L37.0,15.03L41.0,15.17L45.0,15.31L49.0,15.43L53.0,15.52L57.0,15.58L61.0,15.60L65.0,15.57L69.0,15.49L73.0,15.36L77.0,15.18L81.0,14.96L85.0,14.72L89.0,14.45L93.0,14.17L97.0,13.90L101.0,13.64L105.0,13.42L109.0,13.24L113.0,13.12L117.0,13.05L121.0,13.04L125.0,13.10L129.0,13.21L133.0,13.37L137.0,13.57L141.0,13.80L145.0,14.05L148.96,14.16A9.363 9.363 0 0 1 148.96,32.89L145.0,32.78L141.0,32.83L137.0,32.90L133.0,32.97L129.0,33.05L125.0,33.11L121.0,33.16L117.0,33.19L113.0,33.20L109.0,33.19L105.0,33.17L101.0,33.14L97.0,33.10L93.0,33.07L89.0,33.05L85.0,33.06L81.0,33.08L77.0,33.14L73.0,33.23L69.0,33.36L65.0,33.51L61.0,33.69L57.0,33.88L53.0,34.09L49.0,34.28L45.0,34.47L41.0,34.63L37.0,34.75L33.0,34.82L29.0,34.67L25.0,33.97L21.0,33.20L17.0,32.39L13.0,31.55L9.0,30.69L5.0,29.82L5,29.82A5.65 5.65 0 0 1 5,18.52Z" fill="url(#slider-brush-load)" filter="url(#slider-brush-edge)"></path>
<path class="slider-brush__paint" d="M5.0,18.52L9.0,17.92L13.0,17.31L17.0,16.70L21.0,16.10L25.0,15.51L29.0,14.94L33.0,14.90L37.0,15.03L41.0,15.17L45.0,15.31L49.0,15.43L53.0,15.52L57.0,15.58L61.0,15.60L65.0,15.57L69.0,15.49L73.0,15.36L77.0,15.18L81.0,14.96L85.0,14.72L89.0,14.45L93.0,14.17L97.0,13.90L101.0,13.64L105.0,13.42L109.0,13.24L113.0,13.12L117.0,13.05L121.0,13.04L125.0,13.10L129.0,13.21L133.0,13.37L137.0,13.57L141.0,13.80L145.0,14.05L148.96,14.16A9.363 9.363 0 0 1 148.96,32.89L145.0,32.78L141.0,32.83L137.0,32.90L133.0,32.97L129.0,33.05L125.0,33.11L121.0,33.16L117.0,33.19L113.0,33.20L109.0,33.19L105.0,33.17L101.0,33.14L97.0,33.10L93.0,33.07L89.0,33.05L85.0,33.06L81.0,33.08L77.0,33.14L73.0,33.23L69.0,33.36L65.0,33.51L61.0,33.69L57.0,33.88L53.0,34.09L49.0,34.28L45.0,34.47L41.0,34.63L37.0,34.75L33.0,34.82L29.0,34.67L25.0,33.97L21.0,33.20L17.0,32.39L13.0,31.55L9.0,30.69L5.0,29.82L5,29.82A5.65 5.65 0 0 1 5,18.52Z" filter="url(#slider-brush-dry)"></path>
<ellipse class="slider-brush__wet" cx="151.301" cy="23.527" rx="2.6" ry="7.491"></ellipse>
<rect class="slider-brush__ring" x="142.96" y="9" width="23.363" height="30" rx="10"></rect>
</svg>
<p class="slider-brush__value" aria-hidden="true">
<span>Flow</span>
<b>64%</b>
</p>
<input class="slider-brush__input" type="range" min="0" max="100" aria-label="Flow" aria-valuetext="64%" value="64"/>
</div>.slider-brush { position: relative; width: 240px; height: 62px; }
.slider-brush__svg { display: block; width: 240px; height: 48px; overflow: visible; }
.slider-brush__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-dasharray: 1 5; stroke-linecap: round; }
.slider-brush__paint { fill: light-dark(#42454c, #e5e6e9); }
.slider-brush__fade { stop-color: light-dark(#42454c, #e5e6e9); }
.slider-brush__wet { fill: light-dark(#373a40, #f1f2f3); opacity: 0.45; transition: opacity 0.2s; }
.slider-brush:has(.slider-brush__input:active) .slider-brush__wet { opacity: 0.8; }
.slider-brush__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-brush:has(.slider-brush__input:focus-visible) .slider-brush__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-brush__value { position: absolute; left: 0; right: 0; top: 46px; display: flex; justify-content: space-between; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-brush__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-brush__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-brush__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 48px; }
.slider-brush__input::-moz-range-thumb { width: 16px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-brush__wet { transition: none; }
}function initSliderBrush(root) {
var W = 240;
var MID = 24;
var input = root.querySelector('.slider-brush__input');
var paths = root.querySelectorAll('.slider-brush__svg > path');
var load = root.querySelector('.slider-brush__svg linearGradient');
var guide = root.querySelector('.slider-brush__guide');
var wet = root.querySelector('.slider-brush__wet');
var ring = root.querySelector('.slider-brush__ring');
var label = root.querySelector('.slider-brush__value b');
// The brush lands at half pressure and presses down to full width over the first 30px.
function width(x) { return 9.5 * (0.5 + 0.5 * Math.min(1, x / 30)) * (1 + 0.06 * Math.sin(x / 15)); }
function centre(x) { return MID + 0.9 * Math.sin(x / 26); }
/** One stroke from the left edge to e: it swells in, wavers a little and ends in a round, wet tip. */
function stroke(e) {
var top = [];
var bot = [];
var w = 0;
for (var x = 5; x <= e; x += 4) {
w = width(x);
top.push(x.toFixed(1) + ',' + (centre(x) - w).toFixed(2));
bot.unshift(x.toFixed(1) + ',' + (centre(x) + w).toFixed(2));
}
var w0 = width(5);
var y0 = centre(5);
var y = centre(e);
return {
d: 'M' + top.join('L') + 'L' + e + ',' + (y - w).toFixed(2) + 'A' + w + ' ' + w + ' 0 0 1 ' + e + ',' + (y + w).toFixed(2) +
'L' + bot.join('L') + 'L5,' + (y0 + w0).toFixed(2) + 'A' + w0 + ' ' + w0 + ' 0 0 1 5,' + (y0 - w0).toFixed(2) + 'Z',
w: w,
y: y,
};
}
function render() {
var v = +input.value;
var e = 12 + (v / 100) * (W - 26);
var s = stroke(e);
paths.forEach(function (p) { p.setAttribute('d', s.d); });
load.setAttribute('x2', e);
guide.setAttribute('x1', e + s.w + 6);
wet.setAttribute('cx', e + s.w * 0.25);
wet.setAttribute('cy', s.y);
wet.setAttribute('ry', s.w * 0.8);
ring.setAttribute('x', e - 6);
ring.setAttribute('width', s.w + 14);
label.textContent = v + '%';
input.setAttribute('aria-valuetext', v + '%');
}
input.addEventListener('input', render);
return function () {
input.removeEventListener('input', render);
};
}
document.querySelectorAll('.slider-brush').forEach(initSliderBrush);Goal: Create a "Brush" slider.
Visual behavior: A flow slider whose fill is a single brush stroke: it lands at half pressure, runs solid at the start and dry and streaky by the end, with a wet edge at the thumb.
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 stroke is one SVG path built from the value: it widens over its first 30px, wavers slightly along its length and has round ends. It is drawn twice: once through a turbulence displacement filter with a gradient that fades out towards the thumb (the loaded paint), and once through a filter that cuts horizontally stretched noise to a hard edge (the bristle streaks).
- The noise stays fixed in place, so moving the thumb reveals and hides the same strokes rather than redrawing them. A soft ellipse at the tip brightens while you hold it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the tip. Under prefers-reduced-motion the wet edge does not fade.
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 "Brush" slider.
Visual behavior: A flow slider whose fill is a single brush stroke: it lands at half pressure, runs solid at the start and dry and streaky by the end, with a wet edge at the thumb.
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 stroke is one SVG path built from the value: it widens over its first 30px, wavers slightly along its length and has round ends. It is drawn twice: once through a turbulence displacement filter with a gradient that fades out towards the thumb (the loaded paint), and once through a filter that cuts horizontally stretched noise to a hard edge (the bristle streaks).
- The noise stays fixed in place, so moving the thumb reveals and hides the same strokes rather than redrawing them. A soft ellipse at the tip brightens while you hold it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the tip. Under prefers-reduced-motion the wet edge does not fade.
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 flow slider whose fill is a single brush stroke: it lands at half pressure, runs solid at the start and dry and streaky by the end, with a wet edge at the thumb.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, fill, gradient
- Techniques
- svg, svg filter, gradient
- 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 tip. Under prefers-reduced-motion the wet edge does not fade.