Skip to content

feat(slider): add SliderMark and snapPoints for tick marks and pointer snapping - #10498

Open
starboyvarun wants to merge 2 commits into
adobe:mainfrom
starboyvarun:feat/slider-marks-snap-points
Open

feat(slider): add SliderMark and snapPoints for tick marks and pointer snapping#10498
starboyvarun wants to merge 2 commits into
adobe:mainfrom
starboyvarun:feat/slider-marks-snap-points

Conversation

@starboyvarun

@starboyvarun starboyvarun commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Closes #8285

Intent

#8285 asks for five things: ticks along the track, optional labels, clicking a label to set the value, snapping to ticks under the pointer, and keyboard interactions left alone.

Four of those are already buildable on top of RAC. The fifth is not, and @lixiaoyan named the reason in the thread: onChange carries no indication of why the value changed, so userland can't apply one rule for the pointer and another for the keyboard without re-implementing the pointer handling RAC already owns.

The library does know the difference, and useSliderState already has a clean seam for it. So this adds the primitive that can't be built outside, plus the one component needed to compose the rest — and nothing else.

The advanced use case works, and it needed nothing extra

@reidbarber's read was that this is an advanced use case better served by a controlled value. That was fair, so rather than argue it I rebuilt the reference from the issue — the macOS Sound volume slider — end to end, and checked what the API actually had to grow by.

Behaviour in the reference What it needed
Ticks drawn across the track SliderMark (new)
Magnetic snapping to ticks snapPoints (new)
Thumb goes translucent while dragging [data-dragging] — already there
Value readout following the thumb SliderOutput / thumb render props — already there
Level-reactive speaker icon, mute glyph the value you already hold
Keyboard stepping straight through ticks falls out of where snapping lives
volume.icon.mp4

Two additions. Everything else came off render props that already exist. If finishing the demo had needed a third or fourth prop, that would have been a signal the design was wrong; it didn't.

That result is also why the tooltip, the translucency and the icon logic are not in this PR. They are app-level styling, and RAC ships unstyled on purpose.

How it works

setThumbPercent is the pointer door. Every pointer path goes through it (useSliderThumb's move handler, useSlider's track drag and press); no keyboard path does — arrows and Page Up/Down use incrementThumb/decrementThumb, Home/End and the range input use setThumbValue. Putting snapping there makes "keyboard is unaffected" structural rather than a flag someone can forget.

snapPoints / snapThreshold on useSliderState. The threshold is a fraction of the track rather than pixels, so @react-stately stays free of geometry — it already works in percentages and never measures the DOM.

Snap points deliberately bypass step rounding. updateValue splits into commitValue (clamp to the thumb's neighbours, write) and updateValue (snapValueToStep, then commitValue). Snapping calls commitValue directly, because otherwise a snap point at 12.5 on a step={5} slider would round to 15 and defeat the point. Every non-snapping path keeps its original behaviour, and getSnapPoint returns on its first line when snapPoints is undefined.

getClosestThumbIndex moves onto the state. useSlider had this search inline and SliderMark needs the same answer, so it moved rather than got duplicated. Same algorithm, same tie-breaking for stacked thumbs — the existing stacked-thumb tests cover it and are untouched.

A pre-existing drag bug this surfaced

useSlider's track drag is delta-based and seeded its running position from the thumb:

currentPosition.current = state.getThumbPercent(index) * size;

Press the track at 49px, the value adjusts to 50, and the seed becomes 50 while the cursor is at 49 — the thumb then runs 1px ahead of the pointer for the rest of the drag. Snapping made it obvious, but it already exists on main: press at 49.6 with step={5} and you are 0.4px out for the remainder. useSliderThumb never had it, because it seeds before any rounding.

Fixed by seeding from the pointer at mousedown, where the raw percent is already computed. There is a regression assertion for it in the new track test. This is the one change that alters existing behaviour, so it is the part worth reviewing hardest.

Two decisions worth arguing with

Pressing a mark doesn't start a drag. A mark sits inside SliderTrack, which moves the nearest thumb to wherever the pointer landed — so without intercepting, the track overwrites the mark's exact value a moment later, and a label rendered outside the track's bounds gets no handling at all. The mark stops propagation and sets the value itself, which costs drag-from-a-tick. That matches the issue ("clicking on the label should move the slider to the corresponding value"), and a purely decorative tick is pointer-events: none plus an entry in snapPoints. Happy to revisit if you would rather keep drag-through.

Marks are not focusable. The thumb is the keyboard interface; a tab stop per tick would be a regression for keyboard users. They are plain elements, so authors can aria-hidden them where the content only repeats what the thumb announces — the docs say so.

Blast radius

useSliderState and useSlider are shared by every slider in the repo, so this reaches RSP v3 Slider/RangeSlider/ColorSlider, S2 Slider/RangeSlider/ColorSlider, RAC Slider/ColorSlider/ColorArea/ColorWheel, and useColorSliderState (which spreads the base state, so it inherits everything). 116 suites / 2584 tests pass.

Two things to flag rather than leave you to find:

  • SliderState gained a required method. Anyone hand-rolling a SliderState rather than calling useSliderState gets a TypeScript error. Rare, and type-only, but real.
  • setThumbPercent is on the hot drag path and now calls getSnapPoint on every pointer move. It returns on the first line without snapPoints, so existing sliders pay one property check per move.

What this doesn't cover

  • Speed-sensitive snapping ("the behavior may vary depending on movement speed"). macOS does this, but it is unspecified and I would be inventing a heuristic. Distance-based only.
  • Spectrum v3 / S2 tick marks. There is no design spec in the repo for what a Spectrum tick looks like, and inventing the design system's visuals isn't a contributor's call. Happy to follow up if design weighs in.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

Storybook: React Aria Components → Slider → SliderMarks (the macOS volume slider, with a snapThreshold control) and SliderMarksWithLabels.

  1. Drag the thumb across the ticks — it should catch briefly at each one, then release and keep tracking the cursor with no offset. Drag past several and confirm the thumb stays under the pointer.
  2. Press a tick label (SliderMarksWithLabels) — the thumb jumps to exactly that value and onChangeEnd fires once.
  3. Press the track just beside a mark — snaps to the mark; continuing into a drag stays under the cursor.
  4. Keyboard — focus the thumb and hold an arrow key. Movement is by step throughout, including across snap points.
  5. snapThreshold: 0 — snapping fully off.
  6. isDisabled — marks get data-disabled and ignore presses.

What I tested

  • Mouse and keyboard, via the unit tests (143 existing slider tests unchanged and passing, 24 added) and by hand in a browser.
  • RTL and vertical: unit tests assert position (top: 75% for a vertical mark at 25). The positioning expression is the one SliderThumb already uses, so marks and thumbs cannot drift apart.
  • Not verified by me: real touch hardware, screen readers, forced-colors. The touch path is where I would most value a second pair of eyes — pointerdown and touchstart both fire, so one handler acts and the others stop propagation to keep the track from handling the press twice.

On the default for snapThreshold

0.02 of the track — roughly 6px on a 300px track. It is a judgement call about feel rather than something derived, and it is a published default, so if it reads wrong to you I would rather change it now than in a follow-up.

🧢 Your Project:

Personal / open source contribution.

…r snapping

Adds the pieces of adobe#8285 that cannot be built on top
of the current API.

useSliderState gains snapPoints and snapThreshold. Snapping is applied in
setThumbPercent, which only pointer interactions go through, so keyboard
interactions continue to move by step. Snap points bypass step rounding so
that a snap point off the step grid is still reachable with a pointer.

The closest-thumb search moves out of useSlider onto the state as
getClosestThumbIndex, so SliderMark can reuse it.

useSlider now seeds the track drag position from the pointer instead of the
thumb, so a value adjusted to fit the step or a snap point no longer offsets
the rest of the drag.
The story now mirrors the reference in adobe#8285 — ticks drawn
across the track, a pill thumb that goes translucent while dragging, and a live
value readout — with the labelled variant kept as a second story.

Nothing was added to the API to build it: isDragging and isHovered are existing
thumb render props and SliderOutput already reads the live value.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slider: ticks (marks) & snap to ticks

1 participant