Skip to content

feat: add four range slider designs - #131

Merged
starc007 merged 15 commits into
starc007:mainfrom
chakravartiavinit:feat/range-slider-designs
Jul 31, 2026
Merged

feat: add four range slider designs#131
starc007 merged 15 commits into
starc007:mainfrom
chakravartiavinit:feat/range-slider-designs

Conversation

@chakravartiavinit

Copy link
Copy Markdown
Contributor

Adds four slider designs to the Range Slider page, each with its own installSlug, alongside the existing stepped slider.

Design Install Mechanic
Fluid @beui/range-slider-fluid Thumbless pill. The fill is a full-width layer revealed by an animated clip-path inset with a rounded cap, and the label is drawn twice — once on the track, once inside the clip — so it inverts as the fill covers it.
Wave @beui/range-slider-wave Equalizer bars on a gaussian crest centred on the handle, each bar springing with a delay proportional to its distance, so the value reads as a travelling wave.
Bubble @beui/range-slider-bubble Value bubble that pops out of the thumb on grab and reacts to drag velocity: one spring drives a signed lean, and squash/stretch read its magnitude.
Ruler @beui/range-slider-ruler The scale scrolls under a fixed needle instead of a handle moving along a track. Flicks carry momentum and settle onto the nearest tick; fractional steps read at the step's own precision (0.5 → "72.5 kg").

Shared plumbing

All four take their value, step snapping, pointer-capture drag and keyboard handling from lib/hooks/use-slider.ts — controlled/uncontrolled, arrows plus Home/End/PageUp/PageDown, aria-valuemin/max/now, and aria-valuetext when the value carries a unit or suffix. The registry follows the import, so each variant still installs standalone.

The base RangeSlider already used a { 700, 50, 0.5 } spring and two of the new designs wanted the same feel, so it moved to lib/ease.ts as SPRING_GLIDE rather than being copied a third time.

Why clip-path rather than width

The fill started as an animated width. Two artefacts came out of that, both fixed here:

  • At 0% a sub-pixel-wide box still painted its rounded corner as a hairline at the left edge of the track.
  • A separate 12px cap element pinned to the fill edge couldn't shrink below its own width, so the last 12px "stuck" and then vanished in one frame.

Clipping a full-width layer solves both — at 0% the clip is empty, so nothing paints — and it keeps the label from being scaled or re-laid out. The spring is also critically damped on purpose: an under-damped fill undershoots past 0% and rebounds, which reads as a stuck sliver rather than as liquid.

Accessibility

Verified in the browser rather than assumed:

  • Focus. Fluid's ring is ring-inset; an outset ring is clipped away by the track's overflow-hidden and leaves the control with no visible focus at all.
  • Non-text contrast. Unfilled wave bars and minor ruler ticks sit at /45 — measured 4.18:1 dark and 3.14:1 light, above the 3:1 floor in both themes. Filled bars stay at 16–19:1, so the filled-vs-unfilled read is unaffected.
  • Touch targets. Bubble's 8px track carries a 48px hit area; the wrapper's padding keeps that and the bubble inside the component's own box at both extremes.
  • Reduced motion. Bubble's entrance is opacity-only, ruler drops momentum and snaps without a spring, and every transform-based effect is gated behind useReducedMotion().

Ruler's edge fade is a mask-image, not two from-background gradients — the gradient version painted a visible haze band when the slider sat on any surface other than --background.

One behaviour bug worth calling out

Pressing a key while the ruler's momentum was still running swallowed the input: the coasting strip kept committing its own value over the keyboard's, so Home landed on 49.5 instead of 40. A key press now stops the motion value and takes ownership back before delegating.

Checks

bun run check (typecheck, lint, registry) and bun test pass — 97 tests across 13 files, 55 registry components. tests/range-slider-variants.test.tsx adds 21 covering keyboard stepping and clamping, controlled mode, disabled, pointer drag including past-the-edge clamping, the aria-valuetext contract and fractional-step snapping (0.1 stepping stays 72.6/72.7, no float dust). The four sliders are also rows in the axe matrix in tests/a11y.test.tsx.

Measured in the browser during drag, on each design: median frame 16.7ms, worst 17.7ms, no frames over 32ms — including the ruler at 342 tick nodes and the wave at 32 concurrent springs. Also checked at 375px, in both themes, across overdrag, release-outside-the-element, rapid key repeat, window resize and cross-talk between sliders on the same page.

Every tick is in the DOM on the ruler, which is fine into the low hundreds (80 units at step 0.5 is 161) and marked in the source with the windowing upgrade path if a finer step is ever needed.

@starc007

Copy link
Copy Markdown
Owner

Great work on these new slider variants—the visual direction, motion details, accessibility considerations, and shared plumbing are thoughtfully done.

I found a few things worth addressing before merge:

  1. Ruler Slider assumes (max - min) is divisible by step. For example, min=0, max=10, step=4 renders a tick at 12 and can visually settle there while reporting 10.

  2. Bubble Slider rounds fractional values, so 72.5 appears as 73 while its position and accessible value remain 72.5.

  3. The original Range Slider still uses separate value and keyboard logic. PageUp/PageDown work on the four new variants but not the original, despite the catalog saying all five share the same plumbing.

  4. Ruler Slider overwrites a caller-provided formatValueText when no unit is supplied, preventing custom accessible value text.

Could you address these and add coverage for non-divisible ranges and fractional Bubble values?

@avinitDollarpe

Copy link
Copy Markdown
Contributor

Sure @starc007

The base slider carried its own copy of the value state, step snapping,
pointer drag and key handling, so it drifted from the four newer designs:
no PageUp/PageDown, no formatValueText, no float-dust trim on fractional
steps. Delete all of it in favour of useSlider, which the catalog already
claims every design shares.
…not divide

steps rounded (max - min) / step, so a scale like 0-10 by 4 drew a tick at
12 and let the strip settle there while the slider reported 10. Whole ticks
now stop at the last full step and max gets a tick of its own, centred at
its true offset; the drag bounds and the flick snap clamp to that same
offset so the needle can no longer rest past the reported value.
…them

The bubble read Math.round(current), so a half-step scale showed 73 while
the thumb sat at 72.5 and aria-valuenow announced 72.5. The value arrives
already snapped to the step with its float dust trimmed, so it can be read
as it is.
…n one

Each design spread its own formatValueText over the caller's options, so a
formatValueText passed to the ruler without a unit — or to the bubble
without a format — was replaced by undefined and the accessible value text
was lost. Fall back to the built-in one instead of overwriting.
Runs the base slider through the shared variant suite now that it uses the
same hook, and adds cases for a range the step does not divide, a ruler
scale that ends mid-step, a caller-supplied formatValueText and a
fractional value in the bubble.
Every design resolves values through snapSliderValue now. It treats max as a
snap candidate when the step does not divide the range.

- ruler: position ticks by offset instead of appending a flex box for the
  remainder. That box sat after wholeSteps + 1 full ones, so it centred half
  a gap right of the value it marked. No width fixes that when the remainder
  is under half a step.
- ruler: gate the momentum snap on gesture and hold refs, so a late settle
  cannot clear interacting when you grab the scale again or use the keyboard.
- fluid: drop the rounding from the default format. It announced "72%" while
  aria-valuenow said 72.5.
- bubble: a caller's formatValueText now beats format, as it already did in
  fluid.
- base: trim float dust before flooring the tick count. The dot row is inset
  by half the thumb width too, so a dot sits where the thumb lands.
- hook: hold the drag flag in a ref so the first move after pointerdown is
  not dropped. Focus the handle when the track is pressed, and release
  pointer capture only when it is held.
@avinitDollarpe

Copy link
Copy Markdown
Contributor

@starc007 I have made all the changes

@starc007
starc007 merged commit 729a580 into starc007:main Jul 31, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants