Building React Components with Gesture Support

May 8, 2019

It’s perhaps not suprising that gesture support isn’t particularly common on the web. Adding intuitive gestures that match their native counterparts can seem quite daunting and often not worth the trouble. But c’mon, it’s 2019 and the mobile web deserves some decent gesture support!

This article will walk you through the creation of a basic list item which allows you to swipe left to “like” it — a pattern fairly common in native apps, and one that I use quite often in Spotify. Here’s what our final version will look like:

Before getting started, it’s useful to have a basic understanding of React and React hooks. Ready? Let’s do this.

The basic component layout

Before adding any gestures, we need a basic component skeleton & layout. Let’s create a component with a wrapper div element and two children: one will be our background with the heart icon, and the other will provide our sliding foreground element.

Our component will look something like this:

import React from 'react'
import { Heart } from 'react-feather'

function Slider({ children }) {
  return (
    <div className="list-item">
      <div className="background">
        <Heart />
      </div>
      <div className="sliding-pane">{children}</div>
    </div>
  )
}

Our styles are fairly simple. Let’s use a predefined width and height for the list-item element and give it a relative position. And then let’s assign an absolute position to our sliding-pane. Our full css will look like this:

.list-item {
  position: relative;
  width: 300px;
  height: 75px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  text-align: center;
  border-radius: 0.5rem;
}

.sliding-pane {
  cursor: -webkit-grab;
  background-color: #121212;
  color: rgba(255, 255, 255, 0.9);
  position: absolute;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 0.5rem;
  font-size: 1.25em;
  font-weight: 600;
}

.sliding-pane:active {
  cursor: -webkit-grabbing;
}

.background {
  align-self: stretch;
  display: flex;
  flex: 1;
  justify-content: flex-end;
  align-items: center;
}

Alright, we have our basic skeleton. But it doesn’t do anything! Time for some gestures.

Adding drag gestures with react-gesture-responder.

To add gestures we are going to use a library that I developed called react-gesture-responder. React gesture responder is a hook that binds to an element and provides gesture callbacks for that element. It’s a fairly low-level library that allows you to build (and negotiate between) complex gestures. Let’s add it to our component:

import { useGestureResponder } from 'react-gesture-responder'

function Slider({ children }) {
  const { bind } = useGestureResponder({    // the view should claim the responder when touched    onStartShouldSet: () => true,    onMove: state => {      // move callback    },    onRelease: state => {      // release callback    },  })
  return (
    <div className="list-item">
      <div className="background">
        <Heart />
      </div>
      <div {...bind} className="sliding-pane">        {children}
      </div>
    </div>
  )
}

The useGestureResponder hook returns a bind object and accepts a number of callbacks. These callbacks help us control and respond to our gestures.

The bind object should be spread on the element you want to accept gesture controls. What is the bind object, exactly? It’s a collection of event callbacks, such as onTouchStart, onTouchMove, etc., which the hook uses to determine its gestures.

The onStartShouldSet callback tells the view to claim the responder for itself. Typically, you’ll always return true for this unless you want to disable gesture interactions altogether. (Note: you may want to return false in more complex cases involving negotation between competing gestures. But that’s for another tutorial.)

The onMove function is called when a drag is performed, while onRelease is called when that drag ends.

Let’s use these callbacks to update the position of our slider.

function Slider({ children }) {
  const [x, setX] = useState(0)
  const { bind } = useGestureResponder({
    onStartShouldSet: () => true,
    onMove: state => {      const [x] = state.delta      setX(x)    },    onRelease: state => {      setX(0)    },  })

  return (
    <div className="list-item">
      <div className="background">
        <Heart />
      </div>
      <div
        {...bind}        style={{          transform: `translateX(${x}px)`,          transition: `transform 0.2s ease`,
        }}
        className="sliding-pane"
      >
        {children}
      </div>
    </div>
  )
}

Our slider now responds to our drag movements and animates back into place upon release.

But there are a few problems with this code:

  • The transition animation is performed even while dragging. It’d be nice if this was immediately responsive while dragging, but animated upon release.
  • Performance isn’t great because each update to component state causes our entire component to rerender. This might not be a huge issue for a small component like this, but you can imagine performance degrading over time with more complex components.

My preferred solution to these problems is to use an animation library like react-spring.

Using react-spring for better control and performance.

React-spring exposes a useSpring hook which we can use in place of our x state.

Let’s replace this:

const [x, setX] = useState(0)

with useSpring:

import { useSpring } from 'react-spring'

const [{ x }, set] = useSpring(() => {
  return { x: 0 }
})

We now perform animation updates by calling the set function instead of setX.

set({ x: 100 })

React-spring also exports an animated component. These should be used in place of regular div elements when using spring values such as our x value. Let’s put this together:

import { useSpring, animated } from 'react-spring'

function Slider({ children }) {
  const [{ x }, set] = useSpring(() => {    return { x: 0 }  })
  const { bind } = useGestureResponder({
    onStartShouldSet: () => true,
    onMove: state => {
      const [x] = state.delta
      set({ x, immediate: true }) // the immediate flag bypasses the transition animation    },
    onRelease: state => {
      set({ x: 0, immediate: false })    },
  })

  return (
    <div className="list-item">
      <div className="background">
        <Heart />
      </div>
      <animated.div        {...bind}        style={{          transform: x.interpolate(x => `translateX(${x}px)`),        }}        className="sliding-pane"
      >
        {children}
      </animated.div>
    </div>
  )
}

This provides the same functionality as the previous example but fixes both problems mentioned earlier. Performance is improved. And notice the immediate option when calling our set function? This disables the spring transition animation, which provides us with responsive dragging. We disable immediate on release to enable the animation.

Determining when the user has ‘liked’ the list item.

Lastly, we need to determine when the user has gestured far enough to the left to trigger a “like”, and we need to provide visual feedback to the user when they have reached this threshold.

function Slider({ children }) {
  const [isLiking, setIsLiking] = React.useState(false);
  const [{ x }, set] = useSpring(() => {
    return { x: 0 }
  })

  function shouldLike(x) {    return x < -100  }
  const { bind } = useGestureResponder({
    onStartShouldSet: () => true,
    onMove: state => {
      const [x] = state.delta
      const like = shouldLike(x)      if (like !== isLiking) {        setIsLiking(like)      }
      set({ x, immediate: true })
    },
    onRelease: state => {
      if (shouldLike(state.delta[0])) {        console.log('User has liked!)      }
      set({ x: 0, immediate: false })
    },
  })

  return (
    <div className="list-item">
      <div className="background">
        <Heart
          style={{
            color: 'white',            fill: isLiking ? 'white' : 'transparent',            transition: 'transform 0.3s ease'          }}
         />
      </div>
      <animated.div
        {...bind}
        style={{
          transform:  transform: x.interpolate(x => `translateX(${x}px)`),
        }}
        className="sliding-pane"
      >
        {children}
      </animated.div>
    </div>
  )
}

Our move callback checks to see if the user has gestured far enough to the like, and updates our isLiked state accordingly. Our heart icon changes appearance in response. But we don’t actually trigger the like event until the gesture has ended in order to give the user the chance to cancel their gesture.

Further enhancements

This gesture can be improved in a few ways:

  • Ideally we should provide some resistance once the gesture has entered ‘like’ mode.
  • The heart should move horizontally in response to the gesture.

You can see both of these in the final version:

Examples in the wild

I’ve created a few open source components that respond to gestures which might be useful for learning.

Got others you want to share? I’d love to see them!

Contact me on Twitter with any thoughts or questions.
I'd love to hear from you.

Subscribe via RSS
Ben
I'm a web and mobile developer based in British Columbia, Canada. I love creating beautiful, fun, and interactive tools to help people learn.