DEV Community

Cover image for React: Create a turnable knob component
Red Ochsenbein (he/him)
Red Ochsenbein (he/him)

Posted on • Edited on • Originally published at ochsenbein.red

React: Create a turnable knob component

Because I want to learn more about the Web Audio API I started creating a modular synth using React.
One of the main components I knew I'd need was turnable knob. Let me go through the steps to create one.

If you like to see it in action go to https://synth.raven.ch.

Drawing the knob as an SVG

I used Affinity Designer to draw the Knob. The most important part is to split it into into two groups: the turnable knob and the markings.

After creating the SVG I took the code and turned it into a React component and added position prop which allows a value between 0 and 1 to set the position of the knob.

interface KnobMainProps {
  position: number
}
const KnobMain: React.FC<KnobMainProps> = ({ position }) => {
  const angle = Math.min(Math.max(0, position * 270), 270)

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      xmlSpace="preserve"
      style={{
        fillRule: 'evenodd',
        clipRule: 'evenodd',
        strokeLinecap: 'round',
        strokeLinejoin: 'round',
        strokeMiterlimit: 1.5,
      }}
      viewBox="0 0 1024 1024"
    >
      <g>
        <path
          fill="none"
          d="M202.085 686.883C135.815 633.107 94.786 558.75 94.786 476.659c0-163.901 163.552-296.967 365.003-296.967 201.45 0 365.002 133.066 365.002 296.967 0 81.743-40.682 155.817-106.457 209.539"
          style={{
            fill: '#ebebeb',
            fillOpacity: 0,
            stroke: 'currentColor',
            strokeWidth: '13.2px',
          }}
          transform="matrix(1.35193 0 0 1.66166 -109.602 -280.045)"
        />
        <path
          d="m960 960-97.415-97.415"
          style={{
            fill: 'none',
            stroke: 'currentColor',
            strokeWidth: 20,
          }}
        />
        <path
          d="M164.09 859.91 64 960"
          style={{
            fill: 'none',
            stroke: 'currentColor',
            strokeWidth: 20,
          }}
          transform="matrix(.98664 .01336 .01336 .98664 -11.974 11.974)"
        />
      </g>
      <g style={{ cursor: 'pointer', rotate: `${angle}deg`, transformOrigin: '50%', transition: 'rotate 100ms' }}>
        <ellipse
          cx={459.789}
          cy={476.659}
          fill="none"
          rx={365.003}
          ry={296.967}
          style={{
            fill: '#ebebeb',
            fillOpacity: 0,
            stroke: 'currentColor',
            strokeWidth: '15.88px',
          }}
          transform="matrix(1.12427 0 0 1.38185 -4.929 -146.67)"
        />
        <path
          d="M512 512 223.86 800.14"
          style={{
            fill: 'none',
            stroke: 'currentColor',
            strokeWidth: 20,
          }}
        />
      </g>
    </svg>
  )
}
Enter fullscreen mode Exit fullscreen mode

Creating the main component and add mouse wheel interaction

I wanted to be able to set the value using the mouse wheel. But first I needed to setup the component like this:

interface KnobProps {
  label?: string
  max: number
  min: number
  onChange?: (value: number) => void
  step: number
  value: number
}
const Knob: React.FC<KnobProps> = ({ label, onChange, value, step, min, max }) => {
  return (
    <KnobWrapper>
      <KnobMain position={position} />
      <KnobLabel>{label}</KnobLabel>
    </KnobWrapper>
  )
}

// I like styled-components
const KnobWrapper = styled.div`
  width: 2.5rem;
  height: 2.5rem;
  margin-bottom: 0.8rem;
  position: relative;
  touch-action: none;
`

const KnobLabel = styled.div`
  text-align: center;
  margin: 0;
  margin-top: -0.5rem;
  font-size: 0.8rem;
  line-height: 1rem;
`
Enter fullscreen mode Exit fullscreen mode

Then I needed to add the actual mouse wheel handler:

const Knob: React.FC<KnobProps> = ({ label, onChange, value, step, min, max }) => {
  const [value, setValue] = useState(inputValue)

  const handleMouseWheel = useCallback<WheelEventHandler<HTMLDivElement>>(
    (e) => setValue(e.deltaY < 0 ? Math.max(min, value - step) : Math.min(max, value + step)),
    [max, min, step, value]
  )

  const handleChange = useCallback(
    (v: number) => {
      onChange?.(v)
    },
    [onChange]
  )

  useEffect(() => {
    handleChange(value)
  }, [handleChange, value])

  return (
    <KnobWrapper onWheel={handleMouseWheel}>
      <KnobMain position={position} />
      <KnobLabel>{label}</KnobLabel>
    </KnobWrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

The function handleMouseWheel extracts the property deltaY to determine in what direction the mousewheel is turned. Based on that it subtracts or adds a value up to the the set boundaries.
I use the useEffect hook in combination with handleChange to unify calling the onChange callback and setting the actual displayed value.

Adding mouse dragging interaction

The knob should be able to be turned by dragging the mouse. So, I had to implement some click and drag handling.

const Knob: React.FC<KnobProps> = ({ label, onChange, value: inputValue, step, min, max }) => {
  [...]

  const handleDrag = useCallback(
    (e: MouseEvent) => {
      e.preventDefault()
      setValue((prev) => Math.max(min, Math.min(max, prev + -e.movementY * ((max - min) / DRAGGING_DENOMINATOR))))
    },
    [max, min]
  )

  const handleMouseUp = useCallback(() => {
    document.removeEventListener('mousemove', handleDrag)
    document.removeEventListener('mouseup', handleMouseUp)
  }, [handleDrag])

  const handleMouseDown = useCallback(() => {
    document.addEventListener('mousemove', handleDrag)
    document.addEventListener('mouseup', handleMouseUp)
  }, [handleDrag, handleMouseUp])

  return (
    <KnobWrapper onWheel={handleMouseWheel} onMouseDown={handleMouseDown} >
      <KnobMain position={position} />
      <KnobLabel>{label}</KnobLabel>
    </KnobWrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

When the mouse is clicked the onMouseDown callback attaches an event listener to document which allows us to intercept the events on dragging and mouseup wherever on the page the mouse is. The mouseup handler just removes those listeners again. The magic is happening in the dragging handler. Where we retrieve the mouse movement across the y axis and use it to calculate the new value.

Make it work on mobile, too

To make it work on mobile we need to intercept the touch events.

const Knob: React.FC<KnobProps> = ({ label, onChange, value: inputValue, step, min, max }) => {
  [...]

  const touchCoords = useRef<Coords | null>(null)

  const handleTouchMove = useCallback(
    (e: TouchEvent) => {
      if (!touchCoords.current) {
        return
      }
      const delta = touchCoords.current.y - e.touches[0].screenY
      touchCoords.current = { x: e.touches[0].screenX, y: e.touches[0].screenY }
      setValue((prev) => Math.max(min, Math.min(max, prev + delta * ((max - min) / DRAGGING_DENOMINATOR))))
    },
    [max, min]
  )

  const handleTouchEnd = useCallback(() => {
    touchCoords.current = null
    document.removeEventListener('touchmove', handleTouchMove)
    document.removeEventListener('touchend', handleTouchEnd)
  }, [handleTouchMove])

  const handleTouchStart = useCallback<TouchEventHandler<HTMLDivElement>>(
    (e) => {
      touchCoords.current = { x: e.touches[0].screenX, y: e.touches[0].screenY }
      document.addEventListener('touchmove', handleTouchMove)
      document.addEventListener('touchend', handleTouchEnd)
    },
    [handleTouchEnd, handleTouchMove]
  )

  return (
    <KnobWrapper onWheel={handleMouseWheel} onMouseDown={handleMouseDown} onTouchStart={handleTouchStart}>
      <KnobMain position={position} />
      <KnobLabel>{label}</KnobLabel>
    </KnobWrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

The idea is basically the same as with the mouse dragging events. The only difference is that we have to track the touch movement manually. To do this we use a ref, store the starting point and calculate the difference on the touchmove event.

That's it

And this is basically the whole magic of this knob component. At https://synth.raven.ch you can see it in action or you can go to Github to see the full component.

Top comments (1)

Collapse
 
brense profile image
Rense Bakker

Nice! Love the use of svg!