Customizable React Zoom Controller

caner demircicaner demirci
5 min read

https://github.com/canerdemirci/react-zoom-controller/

https://www.npmjs.com/package/react-zoom-controller

I have written a zoom controller in React and published on npm. I wanted this component to be more useful than the HTML slider element. Thanks to the dropdown menu, you can set more zoom levels like 200% and 400%, and quickly input any number. I can summarize the component's properties like this:

  • Zoom slider step size

  • Zoom slider points (25%, 50%, 75%, 100%)

  • Zoom slider width

  • Zoom slider tooltip (optional)

  • Zoom options (dropdown menu - accepts zoom value input) (optional)

  • Customizable style

Example of using the component:

I used css scale for zoom but you can use zoom property

<img src={logo} style={{ transform: `scale(${zoom}%)`, transformOrigin: 'center' }} />
import { useState } from 'react'
import './App.css'
import ZoomController from 'react-zoom-controller'
import logo from './assets/logo.png'

function App() {
  const [zoom, setZoom] = useState<number>(50)

  function handleZoomControllerOnChange(value: number) {
    setZoom(value)
  }

  return (
    <div className="App">
      <div className="logoContainer">
        <h1>React Zoom Controller</h1>
        <img src={logo} style={{ transform: `scale(${zoom}%)`, transformOrigin: 'center' }} />
      </div>
      <div className="controllersSection">
        <ZoomController
          value={zoom}
          onChange={handleZoomControllerOnChange}
        />
        <ZoomController
          value={zoom}
          onChange={handleZoomControllerOnChange}
          sliderWidth={100}
          sliderStyle={{
            trackColor: 'lightgreen',
            valueTrackColor: 'darkgreen',
            thumbColor: 'darkgreen',
            tooltipColor: 'green',
            tooltipTextColor: 'white'
          }}
          selectBoxStyle={{
            borderColor: 'darkgreen',
            textColor: 'white',
            backgroundColor: 'green',
            downArrowColor: 'white',
            optionBackgroundColor: 'teal',
            optionTextColor: 'white'
          }}
        />
        <ZoomController
          value={zoom}
          onChange={handleZoomControllerOnChange}
          sliderWidth={200}
          sliderStyle={{
            trackColor: 'magenta',
            valueTrackColor: 'blue',
            thumbColor: 'blue',
            tooltipColor: 'royalblue',
            tooltipTextColor: 'white'
          }}
          selectBoxStyle={{
            borderColor: 'magenta',
            textColor: 'purple',
            optionBackgroundColor: 'magenta',
            optionTextColor: 'white'
          }}
        />
        <ZoomController
          value={zoom}
          onChange={handleZoomControllerOnChange}
          sliderStepSize={10}
          sliderWidth={300}
          sliderStyle={{
            trackColor: 'orange',
            valueTrackColor: 'red',
            thumbColor: 'red',
            tooltipColor: 'orange',
            tooltipTextColor: 'darkred'
          }}
          selectBoxStyle={{
            borderColor: 'red',
            textColor: 'darkred',
            backgroundColor: 'orange',
            downArrowColor: 'darkred',
            optionBackgroundColor: 'red',
            optionTextColor: 'white'
          }}
        />
        <ZoomController
          value={zoom}
          onChange={handleZoomControllerOnChange}
          sliderWidth={250}
          sliderStepSize={25}
          sliderStyle={{
            trackColor: 'lightblue',
            valueTrackColor: 'teal',
            thumbColor: 'teal',
            tooltipColor: 'lightblue',
            tooltipTextColor: 'teal'
          }}
          selectBoxStyle={{
            borderColor: 'teal',
            textColor: 'teal',
            backgroundColor: 'lightblue',
            downArrowColor: 'teal',
            optionBackgroundColor: 'teal',
            optionTextColor: 'white'
          }}
        />
      </div>
    </div>
  );
}

export default App;

Here are the component's slider codes. You can check the comments for more details:

// Slider types
export interface ISlider {
    value?: number
    stepSize?: number
    sliderWidth?: number
    onChange: (value: number) => void
    toolTipVisibility?: boolean
    sliderStyle?: ISliderStyle
}

export type ISliderStyle = {
    valueTrackColor?: string
    trackColor?: string
    thumbColor?: string
    tooltipColor?: string
    tooltipTextColor?: string
}

import { useEffect, useState } from 'react'
import { ISlider } from './index.types'
import styles from './styles.module.css'

// Initial values
const DEFAULT_VALUE = 100, SLIDER_WIDTH = 150, THUMB_SIZE = 20, STEP_SIZE = 1

// For centering
const calculateThumbPosX = (sliderValue: number, sliderWidth: number): number =>
    sliderValue - (THUMB_SIZE / 2 * 100 / sliderWidth)
const calculateToolTipPosX = (sliderValue: number, sliderWidth: number): number =>
    sliderValue - (44 / 2 * 100 / sliderWidth)

export default function Slider({
    // Percentage 0-100 and 0-500
    value = DEFAULT_VALUE,
    stepSize = STEP_SIZE,
    sliderWidth = SLIDER_WIDTH,
    onChange,
    toolTipVisibility = true,
    sliderStyle
}: ISlider) {
    // It is needed to calculate mouse x position (percentage) on slider
    const sliderRect = document
        .getElementById('zoomContSlider')
        ?.getBoundingClientRect()

    // Mouse x position on viewport
    const [clientX, setClientX] = useState<number>(0)

    useEffect(() => {
        const cvalue = calculateValue(clientX)

        // Update Slider value when it divided exactly with step size
        if (cvalue !== null && cvalue % stepSize === 0) {
            onChange(cvalue)
        }
    }, [clientX])

    // Calculate slider value without taking into account the step size
    // if the mouse's x position on the slider.
    function calculateValue(clientX: number): number | null {
        if (!sliderRect ||
            clientX < sliderRect.left ||
            clientX > sliderRect.right) {
            return null;
        }

        const result = (clientX - sliderRect.left) * 100 / sliderRect.width
        // So that the value can be 100
        return result > 99 ? 100 : Math.round(result)
    }

    function handleMouseDown() {
        document.addEventListener('mouseup', handleMouseUp)
        document.addEventListener('mousemove', handleMouseMove)
    }

    function handleMouseUp() {
        document.removeEventListener('mouseup', handleMouseUp)
        document.removeEventListener('mousemove', handleMouseMove)
    }

    function handleMouseMove(event: MouseEvent) {
        setClientX(event.clientX)
    }

    return (
        <div
            id="zoomContSlider"
            style={{
                width: `${sliderWidth}px`,
                height: '6px'
            }}
            className={styles.slider}
        >
            {/* Tool tip */}
            {toolTipVisibility && <div
                style={{
                    left: `${calculateToolTipPosX(value, sliderWidth)}%`,
                    backgroundColor: sliderStyle?.tooltipColor,
                    color: sliderStyle?.tooltipTextColor
                }}
                className={styles.tooltip}
                >
                {value}
            </div>}
            {/* Slider track */}
            <div 
                style={{backgroundColor: sliderStyle?.trackColor}}
                className={styles.track}
            ></div>
            {/* Slider value track */}
            <div
                style={{ width: `${value}%`, backgroundColor: sliderStyle?.valueTrackColor }}
                className={styles.valueTrack}
                ></div>
            {/* Point buttons */}
            <div className={styles.pointButtons}>
                {[25, 50, 75, 100].map(i => (
                    <div
                        key={i} onClick={() => onChange(i)}
                        style={{
                            left: `${i - (500/sliderWidth)}%`,
                            backgroundColor: sliderStyle?.valueTrackColor
                        }}
                    ></div>
                ))}
            </div>
            {/* Slider thumb */}
            <div
                style={{
                    left: `${calculateThumbPosX(value, sliderWidth)}%`,
                    width: `${THUMB_SIZE}px`,
                    height: `${THUMB_SIZE}px`,
                    backgroundColor: sliderStyle?.thumbColor
                }}
                className={styles.thumb}
                onMouseDown={handleMouseDown}
            ></div>
        </div>
    )
}

Here are the component's dropdown menu codes. You can check the comments for more details:

// Types
export interface ISelectBox {
    value: number
    options: number[]
    selectBoxStyle?: ISelectBoxStyle
    onChange: (value: number) => void
}

export type ISelectBoxStyle = {
    backgroundColor?: string;
    borderColor?: string
    textColor?: string
    downArrowColor?: string
    optionBackgroundColor?: string
    optionTextColor?: string
}

export type MenuOpeningDirection = 'up' | 'down'

import { MouseEvent, useRef, useState } from "react"
import { ISelectBox, MenuOpeningDirection } from "./index.types"
import styles from './style.module.css'

export default function SelectBox(
    { value, options, selectBoxStyle, onChange } : ISelectBox)
{
    // For using input element's select(), blur() functions
    const inputRef = useRef<HTMLInputElement>(null)

    const [menuIsOpen, setMenuIsOpen] = useState<boolean>(false)
    const [menuOpeningDirection, setMenuOpeningDirection] = useState<MenuOpeningDirection>('down')

    // When clicked outside of selectbox close options and unfocus input
    function onClickDocument() {
        setMenuIsOpen(false)
        document.removeEventListener('click', onClickDocument)
        inputRef.current?.blur()
    }

    // Opens options downwards if it's not overflowing otherwise opens upwards
    // Then focus and select input element for number entry.
    function handleSelectBoxOnClick(event: MouseEvent) {
        event.stopPropagation()

        const docHeight = document.documentElement.clientHeight;
        const mouseY = event.clientY;
        const difference = docHeight - mouseY;

        // Options container height = 200px
        if (difference < 200) {
            setMenuOpeningDirection('up')
        } else {
            setMenuOpeningDirection('down')
        }

        setMenuIsOpen((prev) => !prev)
        inputRef.current?.select()
        document.addEventListener('click', onClickDocument)
    }

    // The given zoom percentage can't be higher than highest number of option list.
    function handleInputOnChange(event: React.ChangeEvent<HTMLInputElement>) {
        let val = parseInt(event.target.value)
        const maxInOptions = Math.max(...options)
        val = isNaN(val) ? 100 : val;
        if (val > maxInOptions) {
            val = maxInOptions;
        }
        onChange(val)
    }

    function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
        if (event.key === 'Enter') {
            setMenuIsOpen(false)
            inputRef.current?.blur()
        }
    }

    function handleOptionSelect(option: number) {
        onChange(option)
    }

    return (
        <div
            style={{
                color: selectBoxStyle?.textColor,
                borderColor: selectBoxStyle?.borderColor,
                backgroundColor: selectBoxStyle?.backgroundColor
            }} 
            className={styles.main}
            onClick={handleSelectBoxOnClick}
        >
            <input
                ref={inputRef}
                type="text"
                value={value}
                className={styles.input}
                style={{color: selectBoxStyle?.textColor}}
                onChange={handleInputOnChange}
                onKeyDown={handleInputKeyDown}
            />
            {menuIsOpen && <div className={styles.cursor}></div>}
            <div className={styles.triangle} style={{borderTopColor: selectBoxStyle?.downArrowColor}}></div>
            <div
                className={styles.optionContainer}
                style={{
                    display: menuIsOpen ? 'block' : 'none',
                    position: 'absolute',
                    top: menuOpeningDirection === 'down' ? '100%' : 'unset',
                    bottom: menuOpeningDirection === 'up' ? '100%' : 'unset',
                    width: '100%',
                    zIndex: '999'
                }} 
            >
                {options.map(o => (
                    <div
                        key={o}
                        className={styles.option}
                        style={{
                            color: selectBoxStyle?.optionTextColor,
                            backgroundColor: selectBoxStyle?.optionBackgroundColor,
                        }}  
                        onClick={() => handleOptionSelect(o)}
                    >
                        {o}
                    </div>
                ))}
            </div>
        </div>
    )
}

You can use the component on your react, next projects: https://www.npmjs.com/package/react-zoom-controller

You can see the source code of the component and an example project in my GitHub repository: https://github.com/canerdemirci/react-zoom-controller/

0
Subscribe to my newsletter

Read articles from caner demirci directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

caner demirci
caner demirci

Programming is fun. It is nice to spend time with it and not realize how time passes, learning new things... I hope one day I will make awesome things with it.