Range Slider Bubble

A value bubble rides above the slider thumb, grows while you drag and leans in the direction you are moving.

  • JS
  • Forms & controls
  • Drag
  • Added
64k

AI prompt

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

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.