feat(slider): add SliderMark and snapPoints for tick marks and pointer snapping - #10498
Open
starboyvarun wants to merge 2 commits into
Open
feat(slider): add SliderMark and snapPoints for tick marks and pointer snapping#10498starboyvarun wants to merge 2 commits into
starboyvarun wants to merge 2 commits into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
onChangecarries 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
useSliderStatealready 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.
SliderMark(new)snapPoints(new)[data-dragging]— already thereSliderOutput/ thumb render props — already therevalueyou already holdvolume.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
setThumbPercentis 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 useincrementThumb/decrementThumb, Home/End and the range input usesetThumbValue. Putting snapping there makes "keyboard is unaffected" structural rather than a flag someone can forget.snapPoints/snapThresholdonuseSliderState. The threshold is a fraction of the track rather than pixels, so@react-statelystays free of geometry — it already works in percentages and never measures the DOM.Snap points deliberately bypass step rounding.
updateValuesplits intocommitValue(clamp to the thumb's neighbours, write) andupdateValue(snapValueToStep, thencommitValue). Snapping callscommitValuedirectly, because otherwise a snap point at12.5on astep={5}slider would round to15and defeat the point. Every non-snapping path keeps its original behaviour, andgetSnapPointreturns on its first line whensnapPointsis undefined.getClosestThumbIndexmoves onto the state.useSliderhad this search inline andSliderMarkneeds 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: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 withstep={5}and you are 0.4px out for the remainder.useSliderThumbnever 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 ispointer-events: noneplus an entry insnapPoints. 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-hiddenthem where the content only repeats what the thumb announces — the docs say so.Blast radius
useSliderStateanduseSliderare shared by every slider in the repo, so this reaches RSP v3Slider/RangeSlider/ColorSlider, S2Slider/RangeSlider/ColorSlider, RACSlider/ColorSlider/ColorArea/ColorWheel, anduseColorSliderState(which spreads the base state, so it inherits everything). 116 suites / 2584 tests pass.Two things to flag rather than leave you to find:
SliderStategained a required method. Anyone hand-rolling aSliderStaterather than callinguseSliderStategets a TypeScript error. Rare, and type-only, but real.setThumbPercentis on the hot drag path and now callsgetSnapPointon every pointer move. It returns on the first line withoutsnapPoints, so existing sliders pay one property check per move.What this doesn't cover
✅ Pull Request Checklist:
📝 Test Instructions:
Storybook: React Aria Components → Slider → SliderMarks (the macOS volume slider, with a
snapThresholdcontrol) and SliderMarksWithLabels.onChangeEndfires once.stepthroughout, including across snap points.snapThreshold: 0— snapping fully off.isDisabled— marks getdata-disabledand ignore presses.What I tested
top: 75%for a vertical mark at 25). The positioning expression is the oneSliderThumbalready uses, so marks and thumbs cannot drift apart.pointerdownandtouchstartboth fire, so one handler acts and the others stop propagation to keep the track from handling the press twice.On the default for
snapThreshold0.02of 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.