Engineering · Oct 12, 2021

Getting video to autoplay using Next.js and Tailwind

Recently I added a demo video to Criteria’s homepage.

In this post I'll explain why React's asynchronous layout makes it a bit more difficult to get autoplay working, and how to modify the Dialog component from Headless UI to get autoplay working in React.

The requirements are:

  1. The video should start hidden.
  2. Upon clicking the call-to-action, the video should open in a fullscreen dialog.
  3. When the dialog appears, the video should start playing automatically with sound.

Implementing the dialog

I couldn't find a lightweight lightbox component that I liked and leveraged Tailwind CSS. So I ended up using this modal and modified it to be fullscreen.

The relevant code is below. The <Dialog /> component implements the show/hide functionality and is wrapped inside a <Transition.Root /> component, which provides the animation.

import { Fragment, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'

export default function VideoPlayerDialog() {
  const [open, setOpen] = useState(true)

  return (
    <Transition.Root show={open} as={Fragment}>
      <Dialog as="div" onClose={setOpen}>
        {/* ... */}
      </Dialog>
    </Transition.Root>
  )
}

Hosting the video

I haven't worked much with video on the web and my first instinct was to commit the video file directly to the Git repository!

I wanted to do better and after some research discovered Mux. I liked their developer-centric model and their pricing plan comes with $20 free credit. Considering I only have one video, it was effectively free for me.

Mux provides a guide for integrating it with a React app here. They recommend not to use the autoplay attribute, and instead call video.play(). This is a side-effect, so naturally I called it from within an effect.

import { useRef, useEffect } from 'react'

export default function VideoPlayer() {
  const videoRef = useRef(null)

  useEffect(() => {
    if (videoRef && videoRef.current) {
      videoRef.current.play()
    }
  }, [videoRef])

  return <video controls ref={videoRef} style={{ width: '100%', maxWidth: '500px' }} />
}

When this component is rendered React will execute the effect, which will play the video. Right? Wrong.

Safari video policies

In Safari I got the following error:

NotAllowedError: The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission.

The reason is that Safari prevent websites from playing videos, especially ones with sound, without the user's consent. The way browers infer this consent is whether the code executed as a result of a user gesture. For example, if the code executed inside a click handler then that would indicate the user probably clicked a Play button.

This policy prevents websites from playing unwanted media. But in this case the user explicitly clicks a call-to-action to play the video.

Understanding asynchronous rendering in React

When you make changes to React's state, using setState() or the setter returned from useState(), React may batches these changes into one update operation in order to optimise performance. This means that effect code that runs after a DOM update may not run in the same context as the code that originally changed the state.

You can see this in action using some logging:

<button
  onClick={() => {
    console.log('Begin click handler')
    setOpen(true)
    console.log('End click handler')
  }}
>

If rendering was synchronous, then you would expect the following code to execute between the begin and end markers.

useEffect(() => {
  console.log('Playing video from useEffect')
  if (videoRef && videoRef.current) {
    videoRef.current.play()
  }
})

Instead this is what gets logged:

Begin click handler
End click handler
Playing video from useEffect

This indicated that useEffect is being called asynchronously after the click handler, not within it.

Modifying the Dialog component

After much experimenting I discovered that if I modified how the dialog was shown and hidden I could get it to work. Specifically, I changed the unmount prop to false on the <Transition.Root /> component:

import { Fragment, useState } from 'react'
import { Dialog, Transition } from '@headlessui/react'

export default function VideoPlayerDialog() {
  const [open, setOpen] = useState(true)

  return (
    <Transition.Root show={open} as={Fragment} unmount={false}>
      <Dialog as="div" open={open} onClose={setOpen}>
        {/* ... */}
        <VideoPlayer shouldPlay={open} />
        {/* ... */}
      </Dialog>
    </Transition.Root>
  )
}

This causes the dialog, and hence the video element, to remain in the DOM even when it is not visible.

I also added back the effect to start playing the video:

export default function VideoPlayer({ shouldPlay }) {
  const videoRef = useRef(null)

  useEffect(() => {
    if (shouldPlay && videoRef.current) {
      videoRef.current.play()
    }
  })

  return <video controls ref={videoRef} />
}

Since the video is no longer removed from the DOM completely, it would sometimes start playing with sound in the background when you navigate away from the homepage then return to it. The shouldPlay prop prevents that from happening.

Conclusion

Video on the web is incredibly finicky. I don't know why this works, but it works.