Gap
import { useState } from 'react';
const css = `
.slider-gap {
position: relative;
width: 240px;
height: 64px;
--slider-gap-x: calc(8px + var(--slider-gap-v) * (100% - 16px));
}
.slider-gap__on, .slider-gap__off { position: absolute; top: 24px; height: 16px; }
.slider-gap__on { left: 0; width: max(0px, calc(var(--slider-gap-x) - 8px)); border-radius: 8px 3px 3px 8px; background: light-dark(#42454c, #e5e6e9); }
.slider-gap__off { right: 0; width: max(0px, calc(100% - var(--slider-gap-x) - 8px)); border-radius: 3px 8px 8px 3px; background: light-dark(#ebedf0, #232529); }
.slider-gap__stop { position: absolute; right: 6px; top: 30px; width: 4px; height: 4px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); transition: opacity 0.2s; }
.slider-gap__handle {
position: absolute;
top: 10px;
left: calc(var(--slider-gap-x) - 2px);
width: 4px;
height: 44px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-gap__pill {
position: absolute;
bottom: calc(100% - 4px);
left: var(--slider-gap-x);
min-width: 40px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-size: 13px;
font-weight: 600;
line-height: 28px;
font-variant-numeric: tabular-nums;
text-align: center;
box-shadow: 0 4px 12px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
opacity: 0;
transform-origin: 50% 100%;
transform: translateX(-50%) translateY(8px) scale(0.4);
transition: transform 0.32s cubic-bezier(0.3, 1.5, 0.5, 1), opacity 0.15s;
}
.slider-gap:has(.slider-gap__input:active) .slider-gap__handle { transform: scaleX(0.5); }
.slider-gap:has(.slider-gap__input:active) .slider-gap__pill,
.slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__pill { opacity: 1; transform: translateX(-50%); }
.slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-gap__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-gap__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 64px; }
.slider-gap__input::-moz-range-thumb { width: 16px; height: 64px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-gap__handle, .slider-gap__pill { transition: opacity 0.15s; }
.slider-gap__pill { transform: translateX(-50%); }
}
`;
export function GapSlider() {
const [v, setV] = useState(40);
return (
<>
<style>
{css}
</style>
<div className="slider-gap" style={{ '--slider-gap-v': v / 100 } as React.CSSProperties}>
<span className="slider-gap__on" aria-hidden="true" />
<span className="slider-gap__off" aria-hidden="true" />
{/* The stop dot steps aside before the handle reaches it. */}
<span className="slider-gap__stop" style={{ opacity: v > 92 ? 0 : 1 }} aria-hidden="true" />
<span className="slider-gap__handle" aria-hidden="true" />
<span className="slider-gap__pill" aria-hidden="true">
{v}
</span>
<input
className="slider-gap__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Brightness"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderGap(el) on the mounted element instead.
<div class="slider-gap" style="--slider-gap-v:0.4">
<span class="slider-gap__on" aria-hidden="true"></span>
<span class="slider-gap__off" aria-hidden="true"></span>
<span class="slider-gap__stop" style="opacity:1" aria-hidden="true"></span>
<span class="slider-gap__handle" aria-hidden="true"></span>
<span class="slider-gap__pill" aria-hidden="true">40</span>
<input class="slider-gap__input" type="range" min="0" max="100" aria-label="Brightness" aria-valuetext="40%" value="40"/>
</div>.slider-gap {
position: relative;
width: 240px;
height: 64px;
--slider-gap-x: calc(8px + var(--slider-gap-v) * (100% - 16px));
}
.slider-gap__on, .slider-gap__off { position: absolute; top: 24px; height: 16px; }
.slider-gap__on { left: 0; width: max(0px, calc(var(--slider-gap-x) - 8px)); border-radius: 8px 3px 3px 8px; background: light-dark(#42454c, #e5e6e9); }
.slider-gap__off { right: 0; width: max(0px, calc(100% - var(--slider-gap-x) - 8px)); border-radius: 3px 8px 8px 3px; background: light-dark(#ebedf0, #232529); }
.slider-gap__stop { position: absolute; right: 6px; top: 30px; width: 4px; height: 4px; border-radius: 50%; background: light-dark(#5f626a, #a7abb2); transition: opacity 0.2s; }
.slider-gap__handle {
position: absolute;
top: 10px;
left: calc(var(--slider-gap-x) - 2px);
width: 4px;
height: 44px;
border-radius: 2px;
background: light-dark(#373a40, #f1f2f3);
transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-gap__pill {
position: absolute;
bottom: calc(100% - 4px);
left: var(--slider-gap-x);
min-width: 40px;
height: 28px;
padding: 0 10px;
border-radius: 14px;
background: light-dark(#373a40, #f1f2f3);
color: light-dark(#fbfcfd, #121417);
font-size: 13px;
font-weight: 600;
line-height: 28px;
font-variant-numeric: tabular-nums;
text-align: center;
box-shadow: 0 4px 12px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
opacity: 0;
transform-origin: 50% 100%;
transform: translateX(-50%) translateY(8px) scale(0.4);
transition: transform 0.32s cubic-bezier(0.3, 1.5, 0.5, 1), opacity 0.15s;
}
.slider-gap:has(.slider-gap__input:active) .slider-gap__handle { transform: scaleX(0.5); }
.slider-gap:has(.slider-gap__input:active) .slider-gap__pill,
.slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__pill { opacity: 1; transform: translateX(-50%); }
.slider-gap:has(.slider-gap__input:focus-visible) .slider-gap__handle { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-gap__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-gap__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 64px; }
.slider-gap__input::-moz-range-thumb { width: 16px; height: 64px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-gap__handle, .slider-gap__pill { transition: opacity 0.15s; }
.slider-gap__pill { transform: translateX(-50%); }
}function initSliderGap(root) {
var input = root.querySelector('.slider-gap__input');
var stop = root.querySelector('.slider-gap__stop');
var pill = root.querySelector('.slider-gap__pill');
function update() {
var v = +input.value;
root.style.setProperty('--slider-gap-v', v / 100);
// The stop dot steps aside before the handle reaches it.
stop.style.opacity = v > 92 ? 0 : 1;
pill.textContent = v;
input.setAttribute('aria-valuetext', v + '%');
}
input.addEventListener('input', update);
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-gap').forEach(initSliderGap);Goal: Create a "Gap" slider.
Visual behavior: A thick-track slider split by a slim handle with a gap either side of it: the handle thins as you press, the value rises above it in a pill, and a stop dot marks the end.
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.
- One CSS variable holds the value. The handle's centre, the filled track's width and the empty track's start are all calc()s of it, each kept 8px clear of the handle, with small inner radii facing the gap.
- The pill scales up from the handle under :has(input:active) and :has(input:focus-visible), so it shows while dragging and for keyboard users.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the handle and shows the value pill. Under prefers-reduced-motion the pill appears 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 "Gap" slider.
Visual behavior: A thick-track slider split by a slim handle with a gap either side of it: the handle thins as you press, the value rises above it in a pill, and a stop dot marks the end.
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.
- One CSS variable holds the value. The handle's centre, the filled track's width and the empty track's start are all calc()s of it, each kept 8px clear of the handle, with small inner radii facing the gap.
- The pill scales up from the handle under :has(input:active) and :has(input:focus-visible), so it shows while dragging and for keyboard users.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the handle and shows the value pill. Under prefers-reduced-motion the pill appears 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
A thick-track slider split by a slim handle with a gap either side of it: the handle thins as you press, the value rises above it in a pill, and a stop dot marks the end.
- Dependencies
- None
- Difficulty
- Beginner
- Triggers
- state change, continuous, spring
- Techniques
- css variables, css transition
- 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 handle and shows the value pill. Under prefers-reduced-motion the pill appears without the spring.