Pill
import { useEffect, useRef, useState } from 'react';
const W = 240;
const VALUE_W = 34;
const CLOSED = 14 + 20 + 10 + 10 + VALUE_W + 14;
const TL = 14 + 20 + 10;
const TR = W - 14 - VALUE_W - 10;
const T = 16;
const css = `
.slider-pill { position: relative; width: ${W}px; height: 48px; }
.slider-pill__body {
position: absolute;
top: 2px;
left: 50%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: ${CLOSED}px;
height: 44px;
padding: 0 14px;
border-radius: 22px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
transform: translateX(-50%);
transition: width 0.38s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.slider-pill--open .slider-pill__body { width: ${W}px; }
.slider-pill__icon { flex: none; width: 20px; height: 20px; }
.slider-pill__track { position: relative; flex: 1; min-width: 0; height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); opacity: 0; transition: opacity 0.15s; }
.slider-pill--open .slider-pill__track { opacity: 1; transition-delay: 0.08s; }
.slider-pill__fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: inherit; background: light-dark(#373a40, #f1f2f3); }
.slider-pill__thumb {
position: absolute;
top: 50%;
width: ${T}px;
height: ${T}px;
margin: ${-T / 2}px 0 0 ${-T / 2}px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
transform: scale(0);
transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-pill--open .slider-pill__thumb { transform: scale(1); transition-delay: 0.1s; }
.slider-pill__value { flex: none; width: ${VALUE_W}px; text-align: right; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-pill:has(.slider-pill__input:focus-visible) .slider-pill__body { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-pill__input { position: absolute; left: ${TL - T / 2}px; top: 2px; width: ${TR - TL + T}px; height: 44px; margin: 0; opacity: 0; cursor: pointer; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-pill--open .slider-pill__input { pointer-events: auto; }
.slider-pill__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 44px; }
.slider-pill__input::-moz-range-thumb { width: ${T}px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pill__body, .slider-pill__thumb { transition: none; }
}
`;
export function PillSlider() {
const [v, setV] = useState(60);
const [hover, setHover] = useState(false);
const [keys, setKeys] = useState(false);
const [drag, setDrag] = useState(false);
const [tap, setTap] = useState(false);
const timer = useRef(0);
const open = hover || keys || drag || tap;
// On touch there is no hover: a tap opens it, and it folds away after a moment of no use.
const stay = () => {
clearTimeout(timer.current);
timer.current = window.setTimeout(() => setTap(false), 2500);
};
useEffect(() => () => clearTimeout(timer.current), []);
return (
<>
<style>
{css}
</style>
<div
className={`slider-pill${open ? ' slider-pill--open' : ''}`}
onPointerEnter={(e) => e.pointerType === 'mouse' && setHover(true)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setHover(false)}
onPointerDown={(e) => {
if (e.pointerType === 'mouse') return;
setTap(true);
stay();
}}
>
<div className="slider-pill__body" aria-hidden="true">
<svg className="slider-pill__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor" />
<path className="slider-pill__wave" d="M15.5 9.5a3.5 3.5 0 0 1 0 5" opacity={v > 0 ? 1 : 0.25} />
<path className="slider-pill__wave" d="M18 7a7 7 0 0 1 0 10" opacity={v > 50 ? 1 : 0.25} />
</svg>
<span className="slider-pill__track">
<span className="slider-pill__fill" style={{ width: `${v}%` }} />
<span className="slider-pill__thumb" style={{ left: `${v}%` }} />
</span>
<b className="slider-pill__value">{v}%</b>
</div>
<input
className="slider-pill__input"
type="range"
min={0}
max={100}
value={v}
onFocus={(e) => setKeys(e.currentTarget.matches(':focus-visible'))}
onBlur={() => setKeys(false)}
onPointerDown={() => setDrag(true)}
onPointerUp={() => {
setDrag(false);
if (tap) stay();
}}
onPointerCancel={() => setDrag(false)}
onChange={(e) => {
setV(+e.target.value);
if (tap) stay();
}}
aria-label="Volume"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPill(el) on the mounted element instead.
<div class="slider-pill">
<div class="slider-pill__body" aria-hidden="true">
<svg class="slider-pill__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 9.5h3.5L12 5.5v13l-4.5-4H4z" fill="currentColor"></path>
<path class="slider-pill__wave" d="M15.5 9.5a3.5 3.5 0 0 1 0 5" opacity="1"></path>
<path class="slider-pill__wave" d="M18 7a7 7 0 0 1 0 10" opacity="1"></path>
</svg>
<span class="slider-pill__track">
<span class="slider-pill__fill" style="width:60%"></span>
<span class="slider-pill__thumb" style="left:60%"></span>
</span>
<b class="slider-pill__value">60%</b>
</div>
<input class="slider-pill__input" type="range" min="0" max="100" aria-label="Volume" aria-valuetext="60%" value="60"/>
</div>.slider-pill { position: relative; width: 240px; height: 48px; }
.slider-pill__body {
position: absolute;
top: 2px;
left: 50%;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
width: 102px;
height: 44px;
padding: 0 14px;
border-radius: 22px;
background: light-dark(#ebedf0, #232529);
color: light-dark(#373a40, #f1f2f3);
transform: translateX(-50%);
transition: width 0.38s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.slider-pill--open .slider-pill__body { width: 240px; }
.slider-pill__icon { flex: none; width: 20px; height: 20px; }
.slider-pill__track { position: relative; flex: 1; min-width: 0; height: 4px; border-radius: 2px; background: light-dark(#cbced3, #393c41); opacity: 0; transition: opacity 0.15s; }
.slider-pill--open .slider-pill__track { opacity: 1; transition-delay: 0.08s; }
.slider-pill__fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: inherit; background: light-dark(#373a40, #f1f2f3); }
.slider-pill__thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
box-shadow: 0 1px 4px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.5));
transform: scale(0);
transition: transform 0.25s cubic-bezier(0.3, 1.6, 0.5, 1);
}
.slider-pill--open .slider-pill__thumb { transform: scale(1); transition-delay: 0.1s; }
.slider-pill__value { flex: none; width: 34px; text-align: right; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }
.slider-pill:has(.slider-pill__input:focus-visible) .slider-pill__body { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-pill__input { position: absolute; left: 36px; top: 2px; width: 154px; height: 44px; margin: 0; opacity: 0; cursor: pointer; pointer-events: none; -webkit-appearance: none; appearance: none; background: none; }
.slider-pill--open .slider-pill__input { pointer-events: auto; }
.slider-pill__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 44px; }
.slider-pill__input::-moz-range-thumb { width: 16px; height: 44px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-pill__body, .slider-pill__thumb { transition: none; }
}function initSliderPill(root) {
var input = root.querySelector('.slider-pill__input');
var fill = root.querySelector('.slider-pill__fill');
var thumb = root.querySelector('.slider-pill__thumb');
var value = root.querySelector('.slider-pill__value');
var waves = root.querySelectorAll('.slider-pill__wave');
var hover = false;
var keys = false;
var drag = false;
var tap = false;
var timer = 0;
function sync() { root.classList.toggle('slider-pill--open', hover || keys || drag || tap); }
// On touch there is no hover: a tap opens it, and it folds away after a moment of no use.
function stay() {
clearTimeout(timer);
timer = setTimeout(function () {
tap = false;
sync();
}, 2500);
}
function enter(e) {
if (e.pointerType !== 'mouse') return;
hover = true;
sync();
}
function leave(e) {
if (e.pointerType !== 'mouse') return;
hover = false;
sync();
}
function touch(e) {
if (e.pointerType === 'mouse') return;
tap = true;
stay();
sync();
}
function focus() {
keys = input.matches(':focus-visible');
sync();
}
function blur() {
keys = false;
sync();
}
function down() {
drag = true;
sync();
}
function up() {
drag = false;
if (tap) stay();
sync();
}
function update() {
var v = +input.value;
fill.style.width = v + '%';
thumb.style.left = v + '%';
value.textContent = v + '%';
waves[0].setAttribute('opacity', v > 0 ? 1 : 0.25);
waves[1].setAttribute('opacity', v > 50 ? 1 : 0.25);
input.setAttribute('aria-valuetext', v + '%');
if (tap) stay();
}
root.addEventListener('pointerenter', enter);
root.addEventListener('pointerleave', leave);
root.addEventListener('pointerdown', touch);
input.addEventListener('focus', focus);
input.addEventListener('blur', blur);
input.addEventListener('pointerdown', down);
input.addEventListener('pointerup', up);
input.addEventListener('pointercancel', up);
input.addEventListener('input', update);
return function () {
clearTimeout(timer);
root.removeEventListener('pointerenter', enter);
root.removeEventListener('pointerleave', leave);
root.removeEventListener('pointerdown', touch);
input.removeEventListener('focus', focus);
input.removeEventListener('blur', blur);
input.removeEventListener('pointerdown', down);
input.removeEventListener('pointerup', up);
input.removeEventListener('pointercancel', up);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-pill').forEach(initSliderPill);Goal: Create a "Pill" slider.
Visual behavior: At rest it is a small chip with a speaker icon and the value; hover, focus or tap it and it unfolds into a full volume slider, then folds back once you are done.
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, laid over the track of the open pill only, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The pill is a flex row (icon, track, value) whose width transitions from the chip size to the full width; the track has flex 1 so it grows from nothing as the pill opens, and the thumb scales in after it. The input ignores the pointer while the pill is shut, so the first press never jumps the value.
- It opens on mouse hover, keyboard focus (focus-visible only) and while dragging. Touch has no hover, so a tap opens it and it folds away after 2.5 seconds without use.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus opens the pill and rings it. Under prefers-reduced-motion it opens and closes without the width transition.
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 "Pill" slider.
Visual behavior: At rest it is a small chip with a speaker icon and the value; hover, focus or tap it and it unfolds into a full volume slider, then folds back once you are done.
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, laid over the track of the open pill only, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The pill is a flex row (icon, track, value) whose width transitions from the chip size to the full width; the track has flex 1 so it grows from nothing as the pill opens, and the thumb scales in after it. The input ignores the pointer while the pill is shut, so the first press never jumps the value.
- It opens on mouse hover, keyboard focus (focus-visible only) and while dragging. Touch has no hover, so a tap opens it and it folds away after 2.5 seconds without use.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus opens the pill and rings it. Under prefers-reduced-motion it opens and closes without the width transition.
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
At rest it is a small chip with a speaker icon and the value; hover, focus or tap it and it unfolds into a full volume slider, then folds back once you are done.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, hover, focus
- Techniques
- css transition, state machine
- 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 opens the pill and rings it. Under prefers-reduced-motion it opens and closes without the width transition.