Customizable React Zoom Controller

Customizable React Zoom Controller

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/