• 2019-04-05
  • React
  • How To

Building An Accessible Modal Using React Portal, Higher Ordered Components, and Hooks

Figuring out how to make React Portal work when our app is rendered on the server 🧐. A nice pairing of a higher ordered component and hooks did the trick!

My upcoming side project is built with Next.js, which server side renders each page of the site. While building a React modal window using ReactDOM's createPortal API, I ran into the problem of not being able to create and inject a new div since document doesn't exist on the server.

In this post I will go over how I was able to create a Modal higher ordered component with React Portal that bypasses server side rendering, plus a few more UI elements.

I will be using Typescript (new to Types, ping me to help πŸ™‚). A live demo of our Modal component can be found on CodeSandbox.

Let's get started!

Requirements

Our modal will simply be a higher ordered component, where its children is rendered into a separate div outside of the primary <div id="app" />.

Our modal will:

  • Be able to go through server rendering without blowing up.
  • Let the user manage the state for when the modal is open or closed.
  • Always render an exit button; which will immediately be focused when the modal opens.
  • Make all focusable children elements unfocusable when the modal isn't open, and vice versa with all focusable elements in our main app when the modal is open.
  • Handle Escape key events when opened, effectively closing the modal.
  • Freeze the overlayed content when the modal is opened, only allowing scroll to happen in the modal.

In a new index.tsx file, let's define the types and begin our Modal component:

type ModalPropTypes = {
  children: React.ReactNode
  isOpen: boolean
  handleExit: () => any
  id: string
  root?: string
  focusAfterExit?: HTMLElement
}

export default function Modal({
  children,
  isOpen,
  handleExit,
  id,
  root,
  focusAfterExit,
}: ModalPropTypes): any {
  return null
}

Our Modal will take a few props:

  • children - all the components nested within Modal
  • isOpen - where ever a user uses Modal, they'll manage the state for whether it is open or not.
  • handleExit - same with isOpen, the user will pass a function to toggle isOpen to false.
  • id - a unique identifier for this specific Modal instance.
  • root - the id of the root node that our React app is injected to. With create-react-app it's app, Next.js __next, and Gatsby __gatsby.

Bypassing React Portal During Server Side Rendering

Let's first make sure our modal is only created when we're on the client. To do this, I paired a useRef and useEffect together:

// ...
const [hasUpdated, forceUpdate] = React.useState(false)
const modal = React.useRef<HTMLDivElement | null>(null)

React.useEffect(() => {
  modal.current = document.createElement('div')
  modal.current.id = id

  if (!document.body.querySelector(`#${id}`)) {
    document.body.prepend(modal.current)
  }

  if (!hasUpdated) forceUpdate(true)

  return () => {
    if (modal.current) document.body.removeChild(modal.current)
  }
}, [])

if (modal.current) {
  return ReactDOM.createPortal(
    <>
      <button
        className="exit-button"
        isShowing={isOpen}
        ref={exitButton}
        onClick={() => handleExit()}
      >
        Exit
      </ExitButton>
      {children}
    </>,
    modal.current
  )
}
return null

// ...

Let's break down what's happening.

modal is a reference to either a div or null. On the initial server render, we'll return null since the value of modal.current is null.

Since useEffect runs after our component has already mounted, we can define modal in the effect, and then call our custom forceUpdate function to rerender the component.

This next time around, modal.current is now referencing a DOM node, and we're able to return a ReactDOM.createPortal.

Handling Our UI Requirements In a useEffect

We're going to handle all of our defined UI requirements in a single useEffect 😏. Let's set things up:

// ...

React.useEffect(() => {
  const rootContainer = document.querySelector(`#${root}`)
  const modalContainer = document.querySelector(`#${id}`)

  if (isOpen) {
  } else {
  }

  return () => {}
}, [isOpen])

// ...

We'll create two variables, rootContainer and modalContainer, which will each hold their respective DOM elements, and pass isOpen to useEffect's dependencies array, so the effect will run each time isOpen changes. Let's handle what we'll need to do when our modal is opened:

// ...
if (isOpen) {
  if (exitButton.current) exitButton.current.focus()
  if (modalContainer) toggleTabIndex('on', modalContainer)
  if (rootContainer) toggleTabIndex('off', rootContainer)
  window.addEventListener('keydown', handleKeyDown)
  freeze()
}
// ...

What's happening here is when our Modal component isOpen, we:

  1. Update the current DOM focus to the exit button that comes with our Modal.
  2. Call toggleTabIndex (defined below) to enable focusability(? πŸ€·β€β™‚οΈ) of focusable elements within our modalContainer to 0
  3. Call toggleTabIndex to turn off the focusability of all focusable elements in our rootContainer.
  4. Add an event listener to listen of Escape key clicks, which will just call the handleExit callback our user provides.
  5. Call freeze (defined below) to apply a bit of CSS magic to prevent the document.body from scrolling since our modal UI is overlaying.

Let's define what happens when isOpen is false as well as what to run on unmount:

if (isOpen) {
  // ...
} else {
  if (modalContainer) toggleTabIndex('off', modalContainer)
  if (rootContainer) toggleTabIndex('on', rootContainer)
  window.removeEventListener('keydown', handleKeyDown)
  unfreeze()

  if (focusAfterExit) focusAfterExit.focus()
}

return () => {
  if (isOpen) {
    window.removeEventListener('keydown', handleKeyDown)
    unfreeze()
  }
}

Same thing with when our component isOpen, we're toggling tabindex, removing our keyboard event lister, and calling unfreeze (defined below) to allow users to scroll the document again.

Let's define toggleTabIndex. It'll just be function that applies a tabindex to every focusable element within a specified DOM element:

// ...
// const modalContainer = document.querySelector(`#${root}`)

const toggleTabIndex = (type: 'on' | 'off', container: Element) => {
  const focusableElements = container.querySelectorAll(
    'button, a, input, textarea, select'
  )
  focusableElements.forEach((element: Element) => {
    if (type === 'on') {
      element.removeAttribute('tabindex')
    } else {
      element.setAttribute('tabindex', '-1')
    }
  })
}

// if (isOpen) {
// ...

Next, we'll define our freeze and unfreeze functions, which are actually functions returned from a higher ordered function, with the current scroll position at the time the function was called wrapped in a closure. This way, both freeze and unfreeze have access to the same position even when they're called at different points in time.

// ...
// const toggleTabIndex = (type: 'on' | 'off', container: Element) => { ... }

const capturePosition = () => {
  const cachedPosition = window.pageYOffset
  return {
    freeze: () => {
      // @ts-ignore
      document.body.style = `position: fixed; top: ${cachedPosition *
        -1}px; width: 100%;`
    },
    unfreeze: () => {
      document.body.removeAttribute('style')
      window.scrollTo({
        top: cachedPosition,
      })
    },
  }
}

const { freeze, unfreeze } = capturePosition()

// if (isOpen) {
// ...

Lastly, let's define our keydown event handler:

// const capturePosition = () => {...}

const handleKeyDown = (e: any) => {
  if (e.key === 'Escape') {
    handleExit()
  }
}

// if (isOpen) {
// .../

One last caveat

Sometimes I like a modal's content to transition in and out smoothly. To do so easily, I find the content should always be in the DOM and then we apply CSS transforms/opacity to control how things move in and out.

Because of this, I want all the content on the initial mount to not be focusable/tabbable. Currently our useEffect, when run initially, won't capture the Modal's content since there's nothing before our forceUpdate. So everything in our effect here won't work:

if (isOpen) {
  // ...
} else {
  //❗️On initial mount, this will do nothing since forceUpdate has not updated our component
  if (modalContainer) toggleTabIndex('off', modalContainer)
  if (rootContainer) toggleTabIndex('on', rootContainer)
  window.removeEventListener('keydown', handleKeyDown)
  unfreeze()

  if (focusAfterExit) focusAfterExit.focus()
}

An easy hack would be to do something like this to prevent the content from being in the DOM at all:

<Modal>{isShowing && <MyModalContent />}</Modal>

I found a way around using setTimeout with a delay of 0 (Event Loop):

const initialRender = React.useRef(false)
// React.useEffect(() => {
// ...

if (isOpen) {
  // ...
} else {
  //if (modalContainer) toggleTabIndex('off', modalContainer)
  //if (rootContainer) toggleTabIndex('on', rootContainer)
  //window.removeEventListener('keydown', handleKeyDown)
  //unfreeze()

  //if (focusAfterExit) focusAfterExit.focus()

  if (!initialRender.current) {
    initialRender.current = true
    setTimeout(() => {
      if (modalContainer) toggleTabIndex('off', modalContainer)
    }, 0)
  }
}

// ...

If you have come across this issue and know of a better solution, please teach me!

With that, our Modal component is now functioning; using React's Portal API to render our modal content in its own unique div, works in a server rendered environment, handles keyboard events, focus, and scroll management when our modal is in view.

If you have any questions or comments, feel free to reach out to me. I hope this helps if you run into a similar issue with React Portals and SSR or just with hooks in general!

Previous
  • React
  • Typescript

Create a Ground Up React Calendar Using Native Web APIs & Hooks

Up Next
  • Javascript
  • Web API

Window.scrollTo Alternative Using requestAnimationFrame and CSS Transforms