Wedge
import { useId, useState } from 'react';
const W = 240;
const BASE = 46;
const LOW = 3;
const HIGH = 26;
const css = `
.slider-wedge { position: relative; width: ${W}px; height: 56px; }
.slider-wedge__svg { display: block; width: ${W}px; height: 56px; overflow: visible; }
.slider-wedge__ramp {
transform-box: view-box;
transform-origin: 0 ${BASE}px;
transition: transform 0.5s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-wedge:has(.slider-wedge__input:active) .slider-wedge__ramp { transform: scale(1, 1.3); }
.slider-wedge__track { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linejoin: round; }
.slider-wedge__fill { stroke-width: 4; stroke-linejoin: round; }
.slider-wedge__bar { fill: light-dark(#373a40, #f1f2f3); }
.slider-wedge__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-wedge:has(.slider-wedge__input:focus-visible) .slider-wedge__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-wedge__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-wedge__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 56px; }
.slider-wedge__input::-moz-range-thumb { width: 12px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-wedge__ramp { transition: none; }
}
`;
export function WedgeSlider() {
const [v, setV] = useState(62);
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const t = 6 + (v / 100) * (W - 12); // the native thumb is 12px wide, so its centre runs 6 → W - 6
// Height of the ramp's top edge at any x.
const top = (x: number) => BASE - LOW - ((HIGH - LOW) * (x - 2)) / (W - 4);
const ramp = `2,${BASE} 2,${top(2)} ${W - 2},${top(W - 2)} ${W - 2},${BASE}`;
const yt = top(t) - 7;
return (
<>
<style>
{css}
</style>
<div className="slider-wedge">
<svg className="slider-wedge__svg" aria-hidden="true">
<defs>
<clipPath id={`${id}c`}>
<rect x={-4} y={-20} width={t + 4} height={90} />
</clipPath>
<linearGradient id={`${id}g`} gradientUnits="userSpaceOnUse" x1={2} x2={t} y1={0} y2={0}>
<stop offset="0" style={{ stopColor: 'light-dark(#6d7179, #7d8087)' }} />
<stop offset="1" style={{ stopColor: 'light-dark(#373a40, #f1f2f3)' }} />
</linearGradient>
</defs>
<g className="slider-wedge__ramp">
<polygon className="slider-wedge__track" points={ramp} />
<polygon className="slider-wedge__fill" points={ramp} clipPath={`url(#${id}c)`} fill={`url(#${id}g)`} stroke={`url(#${id}g)`} />
<rect className="slider-wedge__bar" x={t - 2} y={yt} width={4} height={BASE + 4 - yt} rx={2} />
<rect className="slider-wedge__ring" x={t - 6} y={yt - 4} width={12} height={BASE + 12 - yt} rx={6} />
</g>
</svg>
<input
className="slider-wedge__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Volume"
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderWedge(el) on the mounted element instead.
<div class="slider-wedge">
<svg class="slider-wedge__svg" aria-hidden="true">
<defs>
<clipPath id="slider-wedge-clip">
<rect x="-4" y="-20" width="151.36" height="90"></rect>
</clipPath>
<linearGradient id="slider-wedge-fill" gradientUnits="userSpaceOnUse" x1="2" x2="147.36" y1="0" y2="0">
<stop offset="0" style="stop-color:light-dark(#6d7179, #7d8087)"></stop>
<stop offset="1" style="stop-color:light-dark(#373a40, #f1f2f3)"></stop>
</linearGradient>
</defs>
<g class="slider-wedge__ramp">
<polygon class="slider-wedge__track" points="2,46 2,43 238,20 238,46"></polygon>
<polygon class="slider-wedge__fill" points="2,46 2,43 238,20 238,46" clip-path="url(#slider-wedge-clip)" fill="url(#slider-wedge-fill)" stroke="url(#slider-wedge-fill)"></polygon>
<rect class="slider-wedge__bar" x="145.36" y="21.834" width="4" height="28.166" rx="2"></rect>
<rect class="slider-wedge__ring" x="141.36" y="17.834" width="12" height="36.166" rx="6"></rect>
</g>
</svg>
<input class="slider-wedge__input" type="range" min="0" max="100" aria-label="Volume" value="62"/>
</div>.slider-wedge { position: relative; width: 240px; height: 56px; }
.slider-wedge__svg { display: block; width: 240px; height: 56px; overflow: visible; }
.slider-wedge__ramp {
transform-box: view-box;
transform-origin: 0 46px;
transition: transform 0.5s cubic-bezier(0.3, 1.5, 0.5, 1);
}
.slider-wedge:has(.slider-wedge__input:active) .slider-wedge__ramp { transform: scale(1, 1.3); }
.slider-wedge__track { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linejoin: round; }
.slider-wedge__fill { stroke-width: 4; stroke-linejoin: round; }
.slider-wedge__bar { fill: light-dark(#373a40, #f1f2f3); }
.slider-wedge__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-wedge:has(.slider-wedge__input:focus-visible) .slider-wedge__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-wedge__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-wedge__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 56px; }
.slider-wedge__input::-moz-range-thumb { width: 12px; height: 56px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-wedge__ramp { transition: none; }
}function initSliderWedge(root) {
var W = 240, BASE = 46, LOW = 3, HIGH = 26;
var input = root.querySelector('.slider-wedge__input');
var clip = root.querySelector('clipPath rect');
var grad = root.querySelector('linearGradient');
var bar = root.querySelector('.slider-wedge__bar');
var ring = root.querySelector('.slider-wedge__ring');
// Height of the ramp's top edge at any x.
function top(x) { return BASE - LOW - ((HIGH - LOW) * (x - 2)) / (W - 4); }
function update() {
var t = 6 + (input.value / 100) * (W - 12);
var yt = top(t) - 7;
clip.setAttribute('width', t + 4);
grad.setAttribute('x2', t);
bar.setAttribute('x', t - 2);
bar.setAttribute('y', yt);
bar.setAttribute('height', BASE + 4 - yt);
ring.setAttribute('x', t - 6);
ring.setAttribute('y', yt - 4);
ring.setAttribute('height', BASE + 12 - yt);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-wedge').forEach(initSliderWedge);Goal: Create a "Wedge" slider.
Visual behavior: A volume slider shaped as a ramp: the track widens from a hairline to a full wedge, the fill brightens towards the thumb, and the whole ramp swells while you hold it.
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 ramp is one SVG polygon drawn twice: as the track, and as the fill, clipped by a rectangle that ends at the thumb and painted with a gradient that runs from the start to the thumb.
- Corners are rounded by stroking the polygon in its own colour with round joins; the ramp group scales up from its base under :has(input:active).
Accessibility: A native range input with an aria-label; keyboard focus rings the thumb bar. Under prefers-reduced-motion the ramp does not swell.
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 "Wedge" slider.
Visual behavior: A volume slider shaped as a ramp: the track widens from a hairline to a full wedge, the fill brightens towards the thumb, and the whole ramp swells while you hold it.
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 ramp is one SVG polygon drawn twice: as the track, and as the fill, clipped by a rectangle that ends at the thumb and painted with a gradient that runs from the start to the thumb.
- Corners are rounded by stroking the polygon in its own colour with round joins; the ramp group scales up from its base under :has(input:active).
Accessibility: A native range input with an aria-label; keyboard focus rings the thumb bar. Under prefers-reduced-motion the ramp does not swell.
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 volume slider shaped as a ramp: the track widens from a hairline to a full wedge, the fill brightens towards the thumb, and the whole ramp swells while you hold it.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, continuous, fill, gradient
- Techniques
- svg, clip path, gradient, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label; keyboard focus rings the thumb bar. Under prefers-reduced-motion the ramp does not swell.