Custom Slider Component

Published on

Copy Paste Solution:

For the ones in a hurry! Here is the copy paste solution. You can find more of how I got there below!

import { useCallback, useMemo } from "react";

const DataInputSlider = ({ label, tooltip, value, setValue, min, max, step, symbol }) => {
	// need useMemo why? if this component rendered we don't want to recreate a new instance of the configuration object,
	// but recreate it when value gets changed, so Slider will re-render,
	// and you can remove value from dependency array and once the parent value gets updated slider will not be re-renderd
	const sliderProps = useMemo(
		() => ({
			min: min || 0,
			max: max || 100,
			value: value,
			step: step || 1,
			onChange: (e) => sliderValueChanged(e),
		}),
		// dependency array, this will call useMemo function only when value gets changed,
		// if you 100% confident value only updated from Slider, then you can keep empty dependency array
		// and it will not re-render for any configuration object change
		[value]
	);
	//why need useCallback? -> if this component rendered we don't want to recreate the onChange function
	//as this would break the usememo hook because we recreate a new function on every render
	const sliderValueChanged = useCallback((val) => {
		// console.log("NEW VALUE", val);
		setValue(val);
	});
	return (
		<>
			<div className="">
				<strong className="">{label}</strong>
			</div>
			<div className="">
				<div className="">
					{value} {symbol}
				</div>
				<RangeSlider {...sliderProps} aria-label={`${label} slider`} />
			</div>
		</>
	);
};

export default DataInputSlider;

RangeSlider Component

import { memo } from "react";

const RangeSlider = ({ className, onChange, value, ...sliderProps }) => {
	const changeCallback = (e) => {
		onChange(e.target.value); // update parent state of the value when changing
	};

	return (
		<div className="range-slider">
			<input type="range" id="myRange" value={value} {...sliderProps} className={`slider ${className} `} onChange={changeCallback} />
		</div>
	);
};

export default memo(RangeSlider);

Why do it yourself?

The reasons why you might want to create a slider yourself are many. For me it comes simply down to reducing the amount of npm packages you need per project. Sometimes these packages come with a lot of different other components that you don't want or functionallity that you don't need. Not to mention the updates that you need to do to these packages over time. If you just keep adding packages to your project you might need to upadte the stack more often than you want. Another reason is that you have complete control over what happens and of course you learn how it's done.

How its made

Of course we need a slider component first, so we create the RangeSlider component. It should be able to receive the classes we want to inherit to it. I will also need a value and of course we want all the other props to be passed down to it. So our component should look something like this:

const RangeSlider = ({ className, value, ...sliderProps }) => {
	return (
		<div className="range-slider">
			<input type="range" id="myRange" value={value} {...sliderProps} className={`slider ${className} `} />
		</div>
	);
};

export default RangeSlider;

Lets make it look a little better by adding some CSS, I personally like to use SCSS so I will add the following styles for the RangeSlider.

$track-color: #ccf148 !default;
$thumb-color: white !default;

$thumb-radius: 2px !default;
$thumb-height: 24px !default;
$thumb-width: 12px !default;
$thumb-shadow-size: 4px !default;
$thumb-shadow-blur: 4px !default;
$thumb-shadow-color: rgba(0, 0, 0, 0.2) !default;
$thumb-border-width: 2px !default;
$thumb-border-color: #eceff1 !default;

$track-width: 100% !default;
$track-height: 5px !default;
$track-shadow-size: 1px !default;
$track-shadow-blur: 1px !default;
$track-shadow-color: rgba(0, 0, 0, 0.2) !default;
$track-border-width: 0px !default;
$track-border-color: #cfd8dc !default;

$track-radius: 1px !default;
$contrast: 5% !default;

$ie-bottom-track-color: darken($track-color, $contrast) !default;

@mixin shadow($shadow-size, $shadow-blur, $shadow-color) {
    box-shadow: $shadow-size $shadow-size $shadow-blur $shadow-color, 0 0 $shadow-size lighten($shadow-color, 5%);
}

@mixin track {
    cursor: default;
    height: $track-height;
    transition: all 0.2s ease;
    width: $track-width;
}

@mixin thumb {
    @include shadow($thumb-shadow-size, $thumb-shadow-blur, $thumb-shadow-color);
    background: $thumb-color;
    border: $thumb-border-width solid $thumb-border-color;
    border-radius: $thumb-radius;
    box-sizing: border-box;
    cursor: default;
    height: $thumb-height;
    width: $thumb-width;
}

[type="range"] {
    -webkit-appearance: none;
    background: transparent;
    margin: $thumb-height / 2 0;
    width: $track-width;

    &::-moz-focus-outer {
        border: 0;
    }

    &:focus {
        outline: 0;

        &::-webkit-slider-runnable-track {
            background: lighten($track-color, $contrast);
        }

        &::-ms-fill-lower {
            background: $track-color;
        }

        &::-ms-fill-upper {
            background: lighten($track-color, $contrast);
        }
    }

    &::-webkit-slider-runnable-track {
        @include track;
        @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color);
        background: $track-color;
        border: $track-border-width solid $track-border-color;
        border-radius: $track-radius;
    }

    &::-webkit-slider-thumb {
        @include thumb;
        -webkit-appearance: none;
        margin-top: ((-$track-border-width * 2 + $track-height) / 2 - $thumb-height / 2);
    }

    &::-moz-range-track {
        @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color);
        @include track;
        background: $track-color;
        border: $track-border-width solid $track-border-color;
        border-radius: $track-radius;
        height: $track-height / 2;
    }

    &::-moz-range-thumb {
        @include thumb;
    }

    &::-ms-track {
        @include track;
        background: transparent;
        border-color: transparent;
        border-width: ($thumb-height / 2) 0;
        color: transparent;
    }

    &::-ms-fill-lower {
        @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color);
        // background: $ie-bottom-track-color;
        border: $track-border-width solid $track-border-color;
        border-radius: ($track-radius * 2);
    }

    &::-ms-fill-upper {
        @include shadow($track-shadow-size, $track-shadow-blur, $track-shadow-color);
        background: $track-color;
        border: $track-border-width solid $track-border-color;
        border-radius: ($track-radius * 2);
    }

    &::-ms-thumb {
        @include thumb;
        margin-top: $track-height / 4;
    }

    &:disabled {
        &::-webkit-slider-thumb,
        &::-moz-range-thumb,
        &::-ms-thumb,
        &::-webkit-slider-runnable-track,
        &::-ms-fill-lower,
        &::-ms-fill-upper {
            cursor: not-allowed;
        }
    }
}

This should give you a good base to start your slider. You can easily update the styles however you like.

Next we need to create a function that shows the current value of the slider on screen so that its easier for us to maintain it. The component will accept a value and the corresponding callback function from the surrounding environment, as state should be handled on top level. But of course a label and the props that are required to set up the slider.

So we create the parent component like this and introduce useMemo Hook to only rerender the slider if the props change, otherwise the slider would always rerender when the component is rerendered.

import RangeSlider from "@/components/RangeSlider";

const DataInputSlider = ({ label, value, setValue, min, max, step, symbol }) => {
	//need useCallback why? if this component rendered we don't want to recreate the onChange function
	const sliderValueChanged = useCallback((val) => {
		// console.log("NEW VALUE", val);
		setValue(val);
	});
	// why useMemo? if this component rendered we don't want to recreate a new instance of the configuration object,
	// but recreate it when value gets changed, so Slider will re-render,
	// and you can remove value from dependency array and once the parent value gets updated slider will not be re-renderd
	const sliderProps = useMemo(
		() => ({
			min: min || 0,
			max: max || 100,
			value: value,
			step: step || 1,
			onChange: (e) => sliderValueChanged(e),
		}),
		// dependency array, this will call useMemo function only when value gets changed,
		// if you 100% confident value only updated from Slider, then you can keep empty dependency array
		// and it will not re-render for any configuration object change
		[value]
	);

	return (
		<>
			<div className="">
				<strong className="">{label}</strong>
			</div>
			<div className="">
				<div className="">
					{value} {symbol}
				</div>
				<RangeSlider {...sliderProps} aria-label={`${label} slider`} />
			</div>
		</>
	);
};

export default DataInputSlider;

And now we can add one more little tweak to reduce the amount of times that our RangeSlider Component gets rendered. As it is a child component it rerenders whenever the parent is rerendered. This is especially helpful because you might want to use the slider in some kind of form input where each keystroke will actually rerender the entire every component of your form, also the slider. We can avoid rerendering it when it didn't change using the memo hook on the RangeSlider Component which gives us something like this:

import { memo } from "react";

const RangeSlider = ({ className, onChange, value, ...sliderProps }) => {
	const changeCallback = (e) => {
		onChange(e.target.value); // update parent state of the value when changing
	};

	return (
		<div className="range-slider">
			<input type="range" id="myRange" value={value} {...sliderProps} className={`slider ${className} `} onChange={changeCallback} />
		</div>
	);
};

export default memo(RangeSlider);
Affiliate Disclaimer
Disclaimer:
Links on the site might be affiliate links, so if you click them I might earn a small commission.