Proportion Sliders with Locked Sections

Standard range inputs are fine for a single proportion, but multi-thumb sliders where sections need to stay non-overlapping? That’s a different beast. Here, we’ll build one where every section’s width must sum to exactly 100% and the user controls each by dragging its thumb.

Why bother? A budget planner could use it to visualize expense categories. Our motivation came from a movie recommendation app: instead of picking tags, users assign each tag a weight. Heavier tags should carry more influence. This effectively constrains the total to 100%, which conventional inputs don't handle well.

Rendering the Static Component

We’ll use React with TypeScript, though the concepts transfer cleanly to other frameworks. The slider’s data lives in an array that stores each section’s name and color. Percentages sit in a state variable also initialized with Array.fill(): equal parts by default.

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

State keeps percentages as shown. Each section is drawn by a dedicated render component that receives both the data and its percentage.

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

Each TagSection component renders the display name and an associated slider button positioned at its right edge. The container itself maps across the tag data, handing each section its own percentage state:

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 >
}

Two cosmetic details handle the layout:

  • :first-of-type rounds the left side of the first section.
  • :last-of-type rounds the right end and hides the final handle.
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>
}

At this stage the handles don't respond to gestures. We'll wire that up next.

Making Handles Draggable

We want width changes to track the drag in real time. Three problems to solve:

  1. Capture the cursor’s position when the handle is first pressed.
  2. Capture the position during the drag.
  3. Translate movement distance into a percentage-based width change.

Capturing the Click and Drag

The TagSectionProps interface gets an onSliderSelect prop. Attach it to the onPointerDown event, not onMouseDown. Pointer events cover both mouse and touch input in a single listener. The handler passes e.pageX along, giving us the click position:

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

During the drag, a resize listener handles the remaining logic. It attaches to both pointermove and touchmove, then reads the cursor's X coordinate. For touch events, the coordinate is accessed via e.touches, an array; for mouse events, e.pageX works directly. The drag continues until the user lifts their finger or releases the mouse:

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);

Converting Drag Distance to Width

To map pixel movement to percentages, we first need the slider’s overall width. A ref placed on the slider container lets us read its offsetWidth, which returns the element’s layout width as an integer. From there, the speed of the drag can be calculated as a percentage relative to that total:

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

Once we have the X-coordinate from the drag, dividing it by the container’s offsetWidth yields the new percentage. We then assign that value to the current section's index in the _widths state array.

But that's only half done. Sections next to the dragged one haven’t changed, so totals can drift above or below 100%. Percentages can also dip negative if the user drags beyond the section's intended range.

Locking Sections to the 100% Rule

Adjacent sections must react when their neighbor moves. When one section expands, the other must shrink by the exact same amount. This keeps the overall total fixed:

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

The adjustment logic holds globally: dragging one handle only affects the section directly to its right. That means a section can never exceed its own current width plus the neighbor's width — otherwise it would eat into multiple sections. So we compute that maximum allowable value explicitly. Then we clamp the neighboring width to stay within that bound as well, using a range-limiting function that rejects both negative percentages and overflow beyond the max:

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

Finally, after adjusting current and neighbor widths, the bounding function is applied again, ensuring no percentage falls below zero or exceeds the slider's cap. Adding this extra constraint means the entire system cannot overdraw: the maximum possible total is exactly 100%.

Polish: Whole Numbers and Zero-Width Sections

Raw percentage updates can produce long decimals. For a user-facing UI, we typically want clean whole-number percentages. A small helper rounds each value to the requested precision:

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


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

We also guard against sections that hit zero percent. A zero-width section no longer represents a useful slice; the slider stops feeding events into it. That way, only sections actually occupying space remain interactive: