Plough
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const BED = 40;
const CUT = 45;
const BOT = 51;
const at = (v: number) => 10 + (v / 100) * (W - 20);
type Grain = { x: number; y: number; vx: number; vy: number };
const css = `
.slider-plough { position: relative; width: ${W}px; height: 76px; }
.slider-plough__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 56px; overflow: visible; }
.slider-plough__sand { fill: light-dark(#cbced3, #393c41); }
.slider-plough__grit { fill: light-dark(#6d7179, #7d8087); }
.slider-plough__floor { fill: light-dark(#50535b, #c6c8cd); }
.slider-plough__grain { fill: light-dark(#6d7179, #7d8087); }
.slider-plough__blade { fill: light-dark(#373a40, #f1f2f3); }
.slider-plough__handle { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; }
.slider-plough__grip { fill: light-dark(#50535b, #c6c8cd); }
.slider-plough__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-plough:has(.slider-plough__input:focus-visible) .slider-plough__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-plough__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-plough__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-plough__input { position: absolute; left: 0; top: 0; width: 100%; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-plough__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 56px; }
.slider-plough__input::-moz-range-thumb { width: 20px; height: 56px; border: 0; }
`;
export function PloughSlider() {
const [v, setV] = useState(46);
const [, setFrame] = useState(0);
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(46), vx: 0, heap: 0, acc: 0, raf: 0, grains: [] as Grain[] });
// Pushing forward piles sand against the blade, more the faster you push; stop and the heap slumps.
useEffect(() => {
const q = st.current;
const goal = at(v);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
Object.assign(q, { x: goal, vx: 0, heap: 0, grains: [] });
setFrame((f) => f + 1);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.vx += (-300 * (q.x - goal) - 30 * q.vx) * dt;
q.x += q.vx * dt;
const push = Math.max(0, q.vx);
q.heap = Math.min(14, q.heap + push * 0.14 * dt) * Math.exp(-dt * (push > 20 ? 0.3 : q.vx < -20 ? 6 : 2.2));
// Grains spill over the crest and run down the face of the heap.
q.acc += push * 0.08 * dt;
while (q.acc >= 1) {
q.acc -= 1;
if (q.grains.length < 14) q.grains.push({ x: q.x + 3, y: BED - q.heap, vx: push + 25 + Math.random() * 45, vy: -10 - Math.random() * 25 });
}
q.grains = q.grains.filter((g) => {
g.vy += 300 * dt;
g.x += g.vx * dt;
g.y += g.vy * dt;
return g.y < BED;
});
setFrame((f) => f + 1);
if (Math.abs(q.x - goal) < 0.05 && Math.abs(q.vx) < 0.5 && q.heap < 0.05 && !q.grains.length) {
Object.assign(q, { x: goal, vx: 0, heap: 0 });
setFrame((f) => f + 1);
return;
}
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [v]);
const { x, heap, grains } = st.current;
// The heap leans against the blade and falls away at roughly the angle sand rests at.
const len = 6 + heap * 2.4;
let mound = `M ${x + 2} ${BED}`;
for (let s = 0; s <= 8; s++) mound += ` L ${(x + 2 + (len * s) / 8).toFixed(2)} ${(BED - heap * (1 - s / 8) ** 1.6).toFixed(2)}`;
const sand = `${mound} L ${W - 2} ${BED} L ${W - 2} ${BOT} L ${x + 2} ${BOT} Z`;
return (
<>
<style>
{css}
</style>
<div className="slider-plough">
<svg className="slider-plough__svg" aria-hidden="true">
<defs>
<pattern id={`${id}p`} patternUnits="userSpaceOnUse" width={5} height={4}>
<rect className="slider-plough__sand" width={5} height={4} />
<circle className="slider-plough__grit" cx={1} cy={1} r={0.6} />
<circle className="slider-plough__grit" cx={3.5} cy={3} r={0.5} />
</pattern>
</defs>
<rect className="slider-plough__floor" x={2} y={CUT} width={Math.max(0, x - 2)} height={BOT - CUT} rx={2} />
<path className="slider-plough__bed" d={sand} fill={`url(#${id}p)`} />
{grains.map((g, i) => (
<circle key={i} className="slider-plough__grain" cx={g.x} cy={g.y} r={1} />
))}
<line className="slider-plough__handle" x1={x} y1={BED - 18} x2={x - 13} y2={BED - 33} />
<circle className="slider-plough__grip" cx={x - 13} cy={BED - 33} r={3} />
<rect className="slider-plough__blade" x={x - 2} y={BED - 20} width={4} height={CUT - BED + 20} rx={1.5} />
<rect className="slider-plough__ring" x={x - 18} y={BED - 40} width={26} height={CUT - BED + 44} rx={6} />
</svg>
<p className="slider-plough__value" aria-hidden="true">
<span>Cleared</span>
<b>{v}%</b>
</p>
<input className="slider-plough__input" type="range" min={0} max={100} value={v} onChange={(e) => setV(+e.target.value)} aria-label="Cleared" aria-valuetext={`${v}%`} />
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPlough(el) on the mounted element instead.
<div class="slider-plough">
<svg class="slider-plough__svg" aria-hidden="true">
<defs>
<pattern id="slider-plough-sand" patternUnits="userSpaceOnUse" width="5" height="4">
<rect class="slider-plough__sand" width="5" height="4"></rect>
<circle class="slider-plough__grit" cx="1" cy="1" r="0.6"></circle>
<circle class="slider-plough__grit" cx="3.5" cy="3" r="0.5"></circle>
</pattern>
</defs>
<rect class="slider-plough__floor" x="2" y="45" width="109.2" height="6" rx="2"></rect>
<path class="slider-plough__bed" d="M 113.2 40 L 113.20 40.00 L 113.95 40.00 L 114.70 40.00 L 115.45 40.00 L 116.20 40.00 L 116.95 40.00 L 117.70 40.00 L 118.45 40.00 L 119.20 40.00 L 238 40 L 238 51 L 113.2 51 Z" fill="url(#slider-plough-sand)"></path>
<line class="slider-plough__handle" x1="111.2" y1="22" x2="98.2" y2="7"></line>
<circle class="slider-plough__grip" cx="98.2" cy="7" r="3"></circle>
<rect class="slider-plough__blade" x="109.2" y="20" width="4" height="25" rx="1.5"></rect>
<rect class="slider-plough__ring" x="93.2" y="0" width="26" height="49" rx="6"></rect>
</svg>
<p class="slider-plough__value" aria-hidden="true">
<span>Cleared</span>
<b>46%</b>
</p>
<input class="slider-plough__input" type="range" min="0" max="100" aria-label="Cleared" aria-valuetext="46%" value="46"/>
</div>.slider-plough { position: relative; width: 240px; height: 76px; }
.slider-plough__svg { position: absolute; left: 0; top: 0; width: 240px; height: 56px; overflow: visible; }
.slider-plough__sand { fill: light-dark(#cbced3, #393c41); }
.slider-plough__grit { fill: light-dark(#6d7179, #7d8087); }
.slider-plough__floor { fill: light-dark(#50535b, #c6c8cd); }
.slider-plough__grain { fill: light-dark(#6d7179, #7d8087); }
.slider-plough__blade { fill: light-dark(#373a40, #f1f2f3); }
.slider-plough__handle { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.5; stroke-linecap: round; }
.slider-plough__grip { fill: light-dark(#50535b, #c6c8cd); }
.slider-plough__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-plough:has(.slider-plough__input:focus-visible) .slider-plough__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-plough__value { position: absolute; left: 0; right: 0; top: 58px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-plough__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-plough__input { position: absolute; left: 0; top: 0; width: 100%; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-plough__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 56px; }
.slider-plough__input::-moz-range-thumb { width: 20px; height: 56px; border: 0; }function initSliderPlough(root) {
var W = 240;
var BED = 40;
var CUT = 45;
var BOT = 51;
var NS = 'http://www.w3.org/2000/svg';
var input = root.querySelector('.slider-plough__input');
var svg = root.querySelector('.slider-plough__svg');
var floor = root.querySelector('.slider-plough__floor');
var bed = root.querySelector('.slider-plough__bed');
var handle = root.querySelector('.slider-plough__handle');
var grip = root.querySelector('.slider-plough__grip');
var blade = root.querySelector('.slider-plough__blade');
var ring = root.querySelector('.slider-plough__ring');
var label = root.querySelector('.slider-plough__value b');
var x = at(+input.value);
var vx = 0;
var heap = 0;
var acc = 0;
var grains = [];
var raf = 0;
function at(v) { return 10 + (v / 100) * (W - 20); }
function draw() {
// The heap leans against the blade and falls away at roughly the angle sand rests at.
var len = 6 + heap * 2.4;
var d = 'M ' + (x + 2) + ' ' + BED;
for (var s = 0; s <= 8; s++) d += ' L ' + (x + 2 + (len * s) / 8).toFixed(2) + ' ' + (BED - heap * Math.pow(1 - s / 8, 1.6)).toFixed(2);
bed.setAttribute('d', d + ' L ' + (W - 2) + ' ' + BED + ' L ' + (W - 2) + ' ' + BOT + ' L ' + (x + 2) + ' ' + BOT + ' Z');
floor.setAttribute('width', Math.max(0, x - 2));
handle.setAttribute('x1', x);
handle.setAttribute('x2', x - 13);
grip.setAttribute('cx', x - 13);
blade.setAttribute('x', x - 2);
ring.setAttribute('x', x - 18);
}
// Pushing forward piles sand against the blade, more the faster you push; stop and the heap slumps.
function run() {
cancelAnimationFrame(raf);
var goal = at(+input.value);
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
heap = 0;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
vx += (-300 * (x - goal) - 30 * vx) * dt;
x += vx * dt;
var push = Math.max(0, vx);
heap = Math.min(14, heap + push * 0.14 * dt) * Math.exp(-dt * (push > 20 ? 0.3 : vx < -20 ? 6 : 2.2));
// Grains spill over the crest and run down the face of the heap.
acc += push * 0.08 * dt;
while (acc >= 1) {
acc -= 1;
if (grains.length >= 14) continue;
var el = document.createElementNS(NS, 'circle');
el.setAttribute('class', 'slider-plough__grain');
el.setAttribute('r', '1');
svg.insertBefore(el, handle);
grains.push({ el: el, x: x + 3, y: BED - heap, vx: push + 25 + Math.random() * 45, vy: -10 - Math.random() * 25 });
}
grains = grains.filter(function (g) {
g.vy += 300 * dt;
g.x += g.vx * dt;
g.y += g.vy * dt;
if (g.y >= BED) {
g.el.remove();
return false;
}
g.el.setAttribute('cx', g.x);
g.el.setAttribute('cy', g.y);
return true;
});
draw();
if (Math.abs(x - goal) < 0.05 && Math.abs(vx) < 0.5 && heap < 0.05 && !grains.length) {
x = goal;
vx = heap = 0;
return draw();
}
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-plough').forEach(initSliderPlough);Goal: Create a "Plough" slider.
Visual behavior: The thumb is a blade pushing sand: behind it the floor is scraped clean as the fill, and pushing forward builds a heap against the blade, bigger the faster you push, that slumps flat when you stop.
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 blade rides a spring. The heap height grows with the blade's forward speed and decays slowly while pushing, faster once stopped and fastest when pulled back. Its profile falls away from the blade along a power curve, about the angle sand rests at, and lengthens with its height.
- Grains spawn at the crest at a rate set by the push, thrown forward with the blade's speed, and drop off once they reach the bed. The sand is a dotted pattern; the scraped floor sits lower and solid.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the blade. Under prefers-reduced-motion the blade moves straight there with no heap or grains.
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 "Plough" slider.
Visual behavior: The thumb is a blade pushing sand: behind it the floor is scraped clean as the fill, and pushing forward builds a heap against the blade, bigger the faster you push, that slumps flat when you stop.
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 blade rides a spring. The heap height grows with the blade's forward speed and decays slowly while pushing, faster once stopped and fastest when pulled back. Its profile falls away from the blade along a power curve, about the angle sand rests at, and lengthens with its height.
- Grains spawn at the crest at a rate set by the push, thrown forward with the blade's speed, and drop off once they reach the bed. The sand is a dotted pattern; the scraped floor sits lower and solid.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the blade. Under prefers-reduced-motion the blade moves straight there with no heap or grains.
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
The thumb is a blade pushing sand: behind it the floor is scraped clean as the fill, and pushing forward builds a heap against the blade, bigger the faster you push, that slumps flat when you stop.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring, continuous
- 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 blade. Under prefers-reduced-motion the blade moves straight there with no heap or grains.