Range Slider Bubble
A value bubble rides above the slider thumb, grows while you drag and leans in the direction you are moving.
Build a React range slider with a value bubble on top of a native <input type="range"> (0–100) so keyboard and screen reader support come for free. Write the value as a percentage into --pct on the wrapper and use it to fill the track with a linear-gradient and to position a bubble above the thumb with left: calc(var(--pct) * 1% + offset) so it stays centred over the thumb. While dragging (pointerdown until pointerup), scale the bubble from 0.85 to 1 and tilt it by up to 12 degrees in the direction of movement, easing back to 0 degrees 120ms after movement stops. Transitions use 180ms cubic-bezier(0.16, 1, 0.3, 1). Show the value with tabular-nums, for example "64k", and label the input "Budget". Under prefers-reduced-motion, keep the bubble upright and skip the scale.
Source
'use client'
import { useId, useRef, useState } from 'react'
import s from './demo.module.css'
export default function RangeBubble({ labels }: { labels: Record<string, string> }) {
const [value, setValue] = useState(64)
const [dragging, setDragging] = useState(false)
const [tilt, setTilt] = useState(0)
const settle = useRef<ReturnType<typeof setTimeout>>(undefined)
const id = useId()
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const next = Number(e.target.value)
if (dragging) setTilt(Math.max(-12, Math.min(12, (next - value) * 4)))
clearTimeout(settle.current)
settle.current = setTimeout(() => setTilt(0), 120)
setValue(next)
}
return (
<div className={s.wrap} style={{ '--pct': value } as React.CSSProperties}>
<label htmlFor={id} className={s.label}>
{labels.label}
</label>
<div className={s.track}>
<output
htmlFor={id}
className={s.bubble}
data-dragging={dragging || undefined}
style={{ '--tilt': `${tilt}deg` } as React.CSSProperties}
>
{value}
{labels.unit}
</output>
<input
id={id}
type="range"
min={0}
max={100}
value={value}
className={s.input}
onChange={onChange}
onPointerDown={() => setDragging(true)}
onPointerUp={() => setDragging(false)}
onBlur={() => setDragging(false)}
/>
</div>
</div>
)
}
.wrap { display: grid; gap: 8px; width: min(280px, 100%); }
.label { color: var(--stage-muted); font: 500 var(--text-sm) / 1 var(--font-body); }
.track { position: relative; padding-top: 48px; }
.bubble {
--tilt: 0deg;
position: absolute;
top: 0;
/* the thumb is 22px wide, so its centre travels from 11px to (100% - 11px) */
left: calc(11px + (100% - 22px) * var(--pct) / 100);
padding: 6px 10px;
border-radius: 10px;
background: var(--stage-accent);
color: var(--stage-accent-ink);
font: 600 var(--text-sm) / 1 var(--font-body);
font-variant-numeric: tabular-nums;
white-space: nowrap;
transform: translateX(-50%) scale(0.85) rotate(var(--tilt));
transform-origin: 50% 130%;
transition: transform 180ms var(--ease-out);
}
.bubble::after {
content: '';
position: absolute;
left: 50%;
bottom: -5px;
width: 10px;
height: 10px;
background: inherit;
transform: translateX(-50%) rotate(45deg);
z-index: -1;
}
.bubble[data-dragging] { transform: translateX(-50%) scale(1) rotate(var(--tilt)); }
.input {
width: 100%;
height: 22px;
margin: 0;
background: transparent;
appearance: none;
cursor: pointer;
}
.input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--stage-accent) calc(var(--pct) * 1%), var(--stage-rule) 0);
}
.input::-moz-range-track {
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--stage-accent) calc(var(--pct) * 1%), var(--stage-rule) 0);
}
.input::-webkit-slider-thumb {
width: 22px;
height: 22px;
margin-top: -8px;
border: 3px solid var(--stage-accent);
border-radius: 50%;
background: var(--stage-ink);
appearance: none;
}
.input::-moz-range-thumb {
width: 16px;
height: 16px;
border: 3px solid var(--stage-accent);
border-radius: 50%;
background: var(--stage-ink);
}
.input:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 4px; border-radius: 6px; }
@media (prefers-reduced-motion: reduce) {
.bubble, .bubble[data-dragging] { transform: translateX(-50%); transition: none; }
}