Getting video to autoplay using Next.js and Tailwind
Tags: react, tailwindcss, webdev, video
Published: 2021-10-12 07:21:08

Recently I added a demo video to the homepage of [https://criteria.sh](https://criteria.sh).

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](https://headlessui.dev) 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](https://tailwindui.com/components/application-ui/overlays/modals#component-6a0b582f00c5ec38bf748d9a75559f04) 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.

```jsx
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](https://mux.com). 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](https://docs.mux.com/guides/video/stream-video-files#4-watch-your-video). They [recommend](https://docs.mux.com/guides/video/web-autoplay-your-videos) 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](https://reactjs.org/docs/hooks-effect.html).

```jsx
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:

```jsx
<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.

```jsx
  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:

```jsx
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:

```jsx
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.