Let’s Make a Multi-Thumb Slider That Calculates The Width Between Thumbs

Avatar of Simdi Jinkins
Simdi Jinkins on

HTML has an <input type="range">, which is, you could argue, the simplest type of proportion slider. Wherever the thumb of that slider ends up could represent a proportion of whatever is before and whatever is after it (using the value and max attributes). Getting fancier, it’s possible to build a multi-thumb slider. But we’ve got another thing in mind today… a proportion slider with multiple thumbs, and sections that cannot overlap.

Here’s what we’ll be building today:

This is a slider, but not just any slider. It’s a proportion slider. Its various sections must all add up to 100% and the user can adjust the different sections in it.

Why would you need such a thing ?

Maybe you want to build a budget app where a slider like this would consist of your various planned expenses:

I needed something like this to make an anime movie suggestions platform. While researching UX patterns, I noticed that other anime movie suggestion websites have this sort of thing where you select tags to get movie recommendations. That’s cool and all, but how much cooler would it be to add weight to those tags so that recommendations for a tag with a larger weight are prioritized over other tags with lighter weights. I looked around for other ideas, but didn’t find much.

So I built this! And believe it or not, making it is not that complicated. Let me take you through the steps.

The static slider

We’ll be building this in React and TypeScript. That’s not required though. The concepts should port to vanilla JavaScript or any other framework.

Let’s make this slider using dynamic data for the start. We’ll keep the different sections in a variable array with the name and color of each section. 

const _tags = [
  {
    name: "Action",
    color: "red"
  },
  {
    name: "Romance",
    color: "purple"
  },
  {
    name: "Comedy",
    color: "orange"
  },
  {
    name: "Horror",
    color: "black"
  }
];

The width of each tag section is controlled by an array of percentages that add up to 100%. This is done by using the Array.Fill() method to initialize the state of each width:

const [widths, setWidths] = useState<number[]>(new Array(_tags.length).fill(100 / _tags.length))

Next, we’ll create a component to render a single tag section:

interface TagSectionProps {
  name: string
  color: string
  width: number
}


const TagSection = ({ name, color, width }: TagSectionProps) => {
  return <div
    className='tag'
    style={{ ...styles.tag, background: color, width: width + '%' }}
>
    <span style={styles.tagText}>{name}</span>
   <div
     style={styles.sliderButton}
     className='slider-button'>        
     <img src={"https://assets.codepen.io/576444/slider-arrows.svg"} height={'30%'} />
    </div>
  </div >
}

Then we’ll render all of the sections by mapping through the _tags array and return the TagSection component we created above:

const TagSlider = () => {
  const [widths, setWidths] = useState<number[]>((new Array(_tags.length).fill(100 / _tags.length)))
  return <div
    style={{
      width: '100%',
      display: 'flex'
    }}>
    {
    _tags.map((tag, index) => <TagSection
      width={widths[index]}
      key={index}
      name={tag.name}
      color={tag.color}
    />)
    }
  </div>
}

To make rounded borders and hide the last slider button, let’s the :first-of-type and :last-of-type pseudo-selectors in CSS:

.tag:first-of-type {
  border-radius: 50px 0px 0px 50px;
}
.tag:last-of-type {
  border-radius: 0px 50px 50px 0px;
}
.tag:last-of-type>.slider-button {
  display:none !important;
}

Here’s where we are so far. Note the slider handles don’t do anything yet! We’ll get to that next.

Adjustable slider sections

We want the slider sections to adjust move when the slider buttons are dragged with either a mouse cursor or touch, We’ll do that by making sure the section width changes correspond to how much the slider buttons have been dragged. That requires us to answer a few questions:

  1. How do we get the cursor position when the slider button is clicked?
  2. How do we get the cursor position while the slider button is being dragged?
  3. How can we make the width of the tag section correspond to how much the tag section button has been dragged ?

One by one…

How do we get the cursor position when the slider button is clicked?

Let’s add an onSliderSelect event handler to the TagSectionProps interface:

interface TagSectionProps {
  name: string;
  color: string;
  width: number;
  onSliderSelect: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
}

The onSliderSelect event handler is attached to the onPointerDown event of the TagSection component:

const TagSection = ({
  name,
  color,
  width,
  onSliderSelect // Highlight
}: TagSectionProps) => {
  return (
    <div
      className="tag"
      style={{
        ...styles.tag,
        background: color,
        width: width + "%"
        }}
    >
      <span style={styles.tagText}>{name}</span>
      <span style={{ ...styles.tagText, fontSize: 12 }}>{width + "%"}</span>
  
      <div
        style={styles.sliderButton}
        onPointerDown={onSliderSelect}
        className="slider-button"
      >
      <img src={"https://animesonar.com/slider-arrows.svg"} height={"30%"} />
      </div>
    </div>
  );
};

We use onPointerDown instead of onMouseDown to catch both mouse and touch screen events. Here’s more information about that. 

We’re using e.pageX in the onSliderSelect prop function to get the cursor’s position when the slider’s button has been clicked:

<TagSection
  width={widths[index]}
  key={index}
  name={tag.name}
  onSliderSelect={(e) => {
    const startDragX = e.pageX;
  }}
/>

One down!

How do we get the cursor position while the slider button is being dragged?

Now we need to add an event listener to listen for the drag events, pointermove and touchmove. We’ll use these events to cover mouse cursor and touch movements. The section widths need to stop updating once the user’s finger raises from the screen (thus ending the drag): 

window.addEventListener("pointermove", resize);
window.addEventListener("touchmove", resize);


const removeEventListener = () => {
  window.removeEventListener("pointermove", resize);
  window.removeEventListener("touchmove", resize);
}


const handleEventUp = (e: Event) => {
  e.preventDefault();
  document.body.style.cursor = "initial";
  removeEventListener();
}


window.addEventListener("touchend", handleEventUp);
window.addEventListener("pointerup", handleEventUp);

The resize function gives the X coordinate of the cursor while the slider button is being dragged:

const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX
}

When the resize function is triggered by a touch event, e.touches is an array value — otherwise, it’s null, in which case endDragX takes the value of e.pageX

How can we make the width of the tag section correspond to how much the tag section button has been dragged?

To change the width percentages of the various tag sections, let’s get the distance the cursor moves in relation to the entire width of the slider as a percentage. From there, we’ll assign that value to the tag section.

First, we need to get the refof TagSlider using React’s useRef hook:

const TagSlider = () => {
const TagSliderRef = useRef<HTMLDivElement>(null);
  // TagSlider
  return (
    <div
      ref={TagSliderRef}
// ...

Now let’s figure out the width of the slider by using its reference to get the offsetWidth property, which returns the layout width of an element as an integer:

onSliderSelect={(e) => {
  e.preventDefault();
  document.body.style.cursor = 'ew-resize';


  const startDragX = e.pageX;
  const sliderWidth = TagSliderRef.current.offsetWidth;
}};

Then we calculate the percentage distance the cursor moved relative to the entire slider:

const getPercentage = (containerWidth: number, distanceMoved: number) => {
  return (distanceMoved / containerWidth) * 100;
};


const resize = (e: MouseEvent & TouchEvent) => {
  e.preventDefault();
  const endDragX = e.touches ? e.touches[0].pageX : e.pageX;
  const distanceMoved = endDragX - startDragX;
  const percentageMoved = getPercentage(sliderWidth, distanceMoved);
}

Finally, we can assign the newly calculated section width to its index on the _widths state variable:

const percentageMoved = getPercentage(sliderWidth, distanceMoved);
const _widths = widths.slice();
const prevPercentage = _widths[index];
const newPercentage = prevPercentage + percentageMoved


_widths[index] = newPercentage;
setWidths(_widths);

But this isn’t the end! The other sections aren’t changing widths and the percentages can wind up being negative or adding up to more than 100%. Not to mention, the sum of all section widths isn’t always equal to 100% because we haven’t applied a restriction that prevents the overall percentage from changing.

Fixing up the other sections

Let’s make sure the width of one section changes when the section next to it changes.

const nextSectionNewPercentage = percentageMoved < 0 
  ? _widths[nextSectionIndex] + Math.abs(percentageMoved)
  : _widths[nextSectionIndex] - Math.abs(percentageMoved)

This has the effect of reducing the width of the neighboring section if the section increases and vice-versa. We can even shorten it:

const nextSectionNewPercentage = _widths[nextSectionIndex] - percentageMoved

Adjusting a section percentage should only affect its neighbor to the right. This means that the maximum value of a given section maximum percentage should be its width plus the width of its neighbor width when it’s allowed to take up the entire neighbor’s space.

We can make that happen by calculating a maximum percentage value:

const maxPercent = widths[index] + widths[index+1]

To prevent negative width values, let’s restrict the widths to values greater than zero but less than the max percentage: 

const limitNumberWithinRange = (value: number, min: number, max: number):number => {
  return Math.min(Math.max(value,min),max)
}

The limitNumberWithinRange function both prevents negative values and instances where the sum of sections results ina value higher than the maximum percentage. (Hat tip to this StavkOverflow thread.)

We can use this function for the width of the current section and its neighbor:

const currentSectionWidth = limitNumberWithinRange(newPercentage, 0, maxPercent)
_widths[index] = currentSectionWidth


const nextSectionWidth = limitNumberWithinRange(nextSectionNewPercentage, 0, maxPercent);
_widths[nextSectionIndex] = nextSectionWidth;

Extra touches

Right now, the slider calculates the width of each section as a percentage of the entire container to some crazy decimal. That’s super precise, but not exactly useful for this sort of UI. If we want to work with whole numbers instead of decimals, we can do something like this:

const nearestN = (N: number, number: number) => Math.ceil(number / N) * N;
const percentageMoved = nearestN(1, getPercentage(sliderWidth, distanceMoved))

This function approximates the second parameter’s value to the nearest N (specified by the first parameter). Setting  N to 1 like the this example has the effect of making the percentage change in whole numbers instead of tiny incremental decimals.

Another nice to touch is consider additional handling for sections with a zero percentage value. Those should probably be removed from the slider altogether since they no longer take up any proportion of the overall width. We can stop listening for events on those sections:

if (tags.length > 2) {
  if (_widths[index] === 0) {
     _widths[nextSectionIndex] = maxPercent;
    _widths.splice(index, 1);
    setTags(tags.filter((t, i) => i !== index));
    removeEventListener();
  }
  if (_widths[nextSectionIndex] === 0) {
    _widths[index] = maxPercent;
    _widths.splice(nextSectionIndex, 1);
    setTags(tags.filter((t, i) => i !== nextSectionIndex));
    removeEventListener();
  }
}

Voila!

Here’s the final slider: