Ben Heidemann Limited

Document Transition API - Creating a React Hook

Author | Ben Heidemann
10 min read
React Web Platform

In November 2020, the Web Incubator Community Group (WICG) proposed the shared element API. It has since been implemented by the Google Chrome team and is available behind a feature flag in Chrome Canary. Naturally, I tried hacking this into a React hook…


Introduction

If you don’t know what this is about, I would stongly suggest to go watch this video by Jake Archibald and the Google Chrome team. It explains what the API does and the fundamentals of how it works.

In summary, the proposal consists of a few different JavaScript and CSS APIs which enable to creation of arbitrary transition effects for page transitions, in both single-page and multi-page webapps. These are interesting because they allow developers to animate elements in and out, without having to manage complex state with JavaScript.

The Basics

The main API we’re interested in is the document.createDocumentTransition API. This can be used as follows:

let transition = document.createDocumentTransition()

await transition.start(async () => {
  await updateTheDom()
})

This, by default, will cause the whole page to be cross-faded. We can build upon this by telling the browser to individually track certain elements using CSS.

.individually-tracked-element {
  page-transition-tag: individually-tracked-element;
}

These two features alone enable us to do some pretty fancy stuff. Check out this ToDo list animation:

ToDo List Example

There’s plenty more to explore here, like custom CSS animations for transitions. In this post, I’ll won’t go into too much detail as I haven’t fully explored all the possibilities yet.


Creating a React Hook

I’ve recently been playing about with Svelte and SvelteKit, which make animations like the one above pretty easy. Since using Svelte, I have been wondering how I could implement something similar in React. So naturally, when I learned about this API, my first thought was “how can I integrate this with React state updates”.

Turns out, that it’s not too hard! This is the solution I came up with:

import { useState } from 'react'
import { flushSync } from 'react-dom'

export function useTransitionState(initialState) {
  const [state, setStateInternal] = useState(initialState)

  const setState = async (value) => {
    if (!document.createDocumentTransition) {
      return setStateInternal(value)
    }
    const transition = document.createDocumentTransition()
    flushSync(() => void 0)
    await transition.start(() => {
      flushSync(() => {
        setStateInternal(value)
      })
    })
  }

  return [state, setState]
}

This is essentially a thin wrapper around React.useState, and can be used in more or less the same way. By default, it will do a cross-fade when you call setState, but this behaviour can be fully customised with CSS.

You may have noticed the use of flushSync. This achieves two things. The first call, ensures that any queued state updates are immediately rendered to the DOM, ensuring that these are not accidentally animated. The second call, ensures that the state updates are immediately applied to the DOM, and not queued by React. This ensures that the state update will always be animated.


Using the React Hook

So let’s see how we might use this hook to create the above ToDo list:

import { useTransitionState } from 'react-document-transition'
import './App.css'

type TodoItem = {
  name: string
  done: boolean
}

type TodoListState = {
  items: TodoItem[]
}

function Item(item: TodoItem & { toggle: (item: TodoItem) => void }) {
  const slug = item.name.replaceAll(' ', '-').toLowerCase()

  return (
    <div
      onClick={() => item.toggle(item)}
      className={`item ${item.done ? 'done' : ''}`}
      style={
        {
          pageTransitionTag: slug
        } as React.CSSProperties
      }
    >
      <h1>{item.name}</h1>
    </div>
  )
}

function App() {
  const [state, setState] = useTransitionState<TodoListState>({
    items: [
      { done: false, name: 'Buy Milk' },
      { done: false, name: 'Publish NPM Package' },
      { done: false, name: 'Become Rich and Famous' },
      { done: true, name: 'Pay Rent' },
      { done: true, name: 'Create React Document Transition Hook' }
    ]
  })

  function toggle(item: TodoItem) {
    let items: TodoItem[] = Array.from(state.items)

    const foundItem = items.find((maybeItem) => maybeItem.name === item.name)
    if (foundItem) {
      foundItem.done = !foundItem.done
    }

    setState({
      items
    })
  }

  return (
    <div className="App">
      <div className="todo-col">
        {state.items
          .filter((item) => !item.done)
          .map((item) => (
            <Item {...item} toggle={toggle} key={item.name} />
          ))}
      </div>
      <div className="done-col">
        {state.items
          .filter((item) => item.done)
          .map((item) => (
            <Item {...item} toggle={toggle} key={item.name} />
          ))}
      </div>
    </div>
  )
}

export default App

You will notice here, that of the 69 lines of code needed to implement this UI, only four lines are actually controlling the animation:

const slug = item.name.replaceAll(" ", "-").toLowerCase();

// --snip--

style={{
  pageTransitionTag: slug,
} as React.CSSProperties}

This adds the page-transition-css property to the element, which enables Chrome to track the element, even when it is moved to a completely different branch in the DOM tree. Because Chrome knows how to track the element now, it will create a smooth transition between where it is remove and where it is added.


How Can I Use This?

In case you want to try this out yourself, I have published the code as an NPM package. You can install it with:

npm install react-document-transition

I will be messing about with this package a lot over the next few days and I’m excited to see what new possibilities it opens up!


Conclusion

It will likely be some time before this is shipped in all browsers so although react-document-transition gracefully degrades when the createDocumentTransition is unavailable, you probably won’t get much mileage out of it for the time being. However, I’ve got my fingers firmly crossed that someone will release a polyfill for this soon!

Thanks for reading! 🥳



Next Post

The release of Deno 1.21 marks a huge step forward for testing. It includes features such as snapshot testing (which I worked on personally), a BDD style test runner, and mocking utilities. Let’s do a deep dive into why these features are so important for Deno!


Privacy Policy

Ben Heidemann Limited does not track, store or utilise any information relating to your visit to this website. The website may contain links to other websites run by other organisations. This Privacy Notice applies only to our website, so we encourage you to read the privacy statements on the other websites you visit. We cannot be responsible for the privacy policies and practices of other sites even if you access them using links from our website. In addition, if you linked to our website from a third-party site, we cannot be responsible for the privacy policies and practices of the owners and operators of that third-party site and recommend that you check the Privacy Notice of that third-party site.

Contact Details

0/2 23 Bolton Drive
Glasgow
G42 9DX
tel: +44 7472 564288
email: ben@heidemann.dev
© Copyright Ben Heidemann Limited All Rights Reserved