At Makeswift we recently released a new pricing model and with it, a new slider component. Along the way, I learned a thing or two about drag and drop from Alan, which is what I will be sharing below.
In this post, I’ll explain the complexities of a slider component that mimics native behavior with keyboard accessibility and resizability.
Here is what we are making:
import { useState } from "react"; import { Circle, Square, Triangle } from "./shapes"; import { Slider } from "./slider"; export default function Home() { const [state, setState] = useState<number>(0); return ( <div className="px-10"> <Slider className="w-full max-w-3xl mx-auto mt-5" stops={[ <Square className="fill-[#AB87FF]" />, <Circle className="fill-[#446DF6]" />, <Triangle className="fill-[#FBB02D]" />, <Square className="fill-[#ACECA1]" />, <Circle className="fill-[#FFFF4C]" />, <Square className="fill-[#FE65B7]" />, ]} value={state} onChange={(value: number) => setState(value)} /> </div> ); }
Component API
A slider is an enhanced <input type="range" />
component.
So for the API I wanted to use value
and onChange
so that
- it is ready to use as a controlled input
- it is swappable with native inputs
Additionally, there is also a stops
prop, which is of type ReactNode[]
in our example.
This is incidental. I wanted to show basic shapes as the stop labels, and in most apps the type would probably be something like
Our Slider
in use looks something like:
Internal structure
The internal structure of our slider is comprised of three divs: container
, track
, and thumb
.
The track
is the background of our slider component. It renders stops
and handles onClick
events.
The thumb
is the circle that slides along as the value
changes. It renders the current value
and handles onPointerMove
events.
The names track
and thumb
come from the ids on the divs that are used in the shadow dom implementation of the <input type="range" />
To see for yourself enable the shadow dom like so:
And inspect this <input type="range" />
A draggable thumb
The slider component we are creating is going to mimic a controlled input, where value
and onChange
are used to hold state externally.
While value
is external, we will be using a piece of internal derived state for the x position
of the thumb.
This x position
should reflect changes to our value
by transforming the thumb along our track.
Updating value
based on user input
In the onPointerMove
we
- Get the width of the container
container.current.getBoundingClientRect();
- Divide that into segments based on the number of stops
const segmentWidth = containerWidth / (stops.length - 1);
- Find the closest index to the current mouse position
const index = Math.round((e.clientX - containerLeft) / segmentWidth)
- Clamp that index the min and max values allowed
const clampedIndex = clamp(index, 0, stops.length - 1);
- And update the
value
to reflect the changeonChange(clampedIndex);
onPointerUp
then cleans up the pointermove
event listener.
This pattern of setting move
/up
listeners in the down
handler was new to me and is something to keep in mind when looking through DnD libraries.
Thanks Alan for walking me through it!
Syncing position
with value
Ok so our value
is updating, but the thumb is still not moving. Let's
1 - Create state to hold thumb position
2 - Create an effect that sets thumb position
on first render
-
setPosition(value * segmentWidth);
- With the segment derived from the container and number of stops we can set
position
by multiplyingvalue
andsegementWidth
- With the segment derived from the container and number of stops we can set
-
position != null
- Then we can conditionally render our
thumb
to appear on render #2 once the effect has initializedposition
- Then we can conditionally render our
3 - Update our onPointerMove
to keep position
in sync with value
and 4 - Animate the thumb based on the position
absolute left-0 top-1/2
+y:-50%
- Here we are absolutely positioning the thumb left aligned and vertically centered
- Framer Motion uses inline styles that override CSS classes, so we are setting
y:-50%
in theanimate
prop to avoid losing our vertical center on first animation.
x: position - 14
- We transform the thumb along the track offsetting it by -14 (1/2 of the 28px width) to horizontally center it on top of the current stop.
A clickable track
Now that we have the thumb interaction, let's work on rendering the track behind it.
The onClick
is a copy of the onPointerMove
. They are both taking the e.clientX
and mapping it to value
.
Your mileage with this section will vary based on your design, but for my design I
- Create a new array that represents the segments between stops
Array.from({ length: stops.length - 1 })
- And map through it to render a background color
Styling touches
At this point our slider is working, but we don't have a way to set styles on drag. Enter isDragging
We can use this state to
- Set the resize cursor on the container
<div className={classNames({["cursor-ew-resize"]: isDragging })} />
- Set the border color on the thumb
<div className={classNames({["border-purple hover:border-purple"]: isDragging })} />
- Note: I am setting the border for the
hover
state so that tailwind overrides thehover:border-medium
that is already in place.
Keyboard shortcuts
At this point we still can't focus our input or use any keyboard shortcuts we would expect from a <input type="range" />
. Let's fix that!
Focusablility
Our slider is composed entirely of divs, so to make it focusabled we need to add a tab index.
Adding the actual shortcuts
For this I am deffering to the react binding of hotkeys-js, react-hotkeys-hook
This is similar to the onPointerMove
aside from this line.
When someone uses the left
hotkey we can decrement the value
, and conversely when they use the right
hotkey increment it.
Keyboard styles
Now to match the mouse styles we can set isDragging
on focus
and blur
.
Resizability
The final part of our demo is adding a resize observer so that when the size of the browser changes our thumb position
gets updated.
I am using a useEffect
to set an event listener for the resize
event.
When cleanup is necessary in a useEffect
I have tended to enjoy this "add, remove, and then define" pattern.
Haven't seen it all over the place, but I think it makes reading useEffects
a bit easier.
This post is a part of a series I am writing on animated and accessible components. (details here)
But that's all for this example. Hope it was helpful! If you still are confused HMU with any feedback you have at the email in the footer :)
Links
- Component Demo
- Component Github
- Other Components I have written about
- Video to similar topic
- Sam Selikoff is such a great instructor. I haven't even had time to watch this video but I know it will be :chef's kiss: