Building Accessible Modals in React with Bootstrap and Portals

March 10, 2021 · 4 min read

Update — 2026-04-04: This post has been updated to improve clarity and structure. Key changes include refined code blocks, improved accessibility explanations, and updated formatting for modern MDX standards.

I know it has been a while and I haven't posted anything, but I've been busy lately doing a lot of React development using TypeScript, some Prisma, and NestJS fun stuff in the backend. Now let’s get started.

I'm not a huge fan of reactstrap or react-bootstrap because I enjoy doing things manually. So, I decided to build the modal core functionality myself—excluding the styles, which will be handled by Bootstrap—while creating something accessible that uses the latest React features like hooks and portals.

Project Setup

First, let’s create a new React project using TypeScript:

yarn create react-app react-bootstrap-modal --template typescript

After that, install the necessary dependencies:

yarn add bootstrap@next node-sass lodash.kebabcase
yarn add -D @types/lodash.kebabcase

Rename your index.css file to main.scss, remove its content, and add this line:

@import '~bootstrap/scss/bootstrap.scss';

Creating the Modal Components

We will create a components/Modal folder to house all modal-related logic.

1. Modal Overlay (Portal)

First, we create a React Portal that acts as an overlay shadow when the modal opens:

// components/Modal/ModalOverlay/index.tsx
import ReactDOM from 'react-dom'

export default function ModalOverlay() {
  return ReactDOM.createPortal(
    <div className='modal-backdrop fade show' />,
    document.body,
  )
}

2. Shared Types

Let's define the shared types to ensure strict typing across our components.

// components/Modal/shared.types.ts
import React from 'react'

export type BtnRef =
  | string
  | ((instance: HTMLButtonElement | null) => void)
  | React.RefObject<HTMLButtonElement>
  | null
  | undefined

type CallbackChildren = (close: () => void) => React.ReactNode

export type MainChildren = React.ReactNode | CallbackChildren

export type ModalSize = 'sm' | 'lg' | 'xl'

3. Modal Button

This component wraps a regular button and handles the reference to the DOM element.

// components/Modal/ModalButton/index.tsx
import React from 'react'
import { BtnRef } from '../shared.types'

interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  buttonRef: BtnRef
}

export default function ModalButton({
  buttonRef,
  children,
  type = 'button',
  ...rest
}: Props) {
  return (
    <button ref={buttonRef} type={type} {...rest}>
      {children}
    </button>
  )
}

Ensuring Accessibility: Focus Trap Hook

To make the modal accessible, we need to ensure that focus remains inside the modal while it's open.

// hooks/useFocusTrap.ts
import React from 'react'

const KEYCODE_TAB = 9
const FOCUSABLE_ELEMENTS = 'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'

export function useFocusTrap() {
  const ref = React.useRef<HTMLDivElement>(null)

  function handleFocus(event: KeyboardEvent) {
    const focusableEls = [
      ...ref.current!.querySelectorAll(FOCUSABLE_ELEMENTS),
    ].filter((el) => !el.hasAttribute('disabled')) as HTMLElement[]
    
    const firstFocusableEl = focusableEls[0]
    const lastFocusableEl = focusableEls[focusableEls.length - 1]

    const isTabPressed = event.key === 'Tab' || event.keyCode === KEYCODE_TAB

    if (!isTabPressed) return

    if (event.shiftKey) {
      if (document.activeElement === firstFocusableEl) {
        lastFocusableEl.focus()
        event.preventDefault()
      }
    } else if (document.activeElement === lastFocusableEl) {
      firstFocusableEl.focus()
      event.preventDefault()
    }
  }

  React.useEffect(() => {
    const currentRef = ref.current!
    currentRef.addEventListener('keydown', handleFocus)
    return () => currentRef.removeEventListener('keydown', handleFocus)
  }, [])

  return ref
}

This component handles the rendering of the modal dialog, including animations and ARIA attributes.

// components/Modal/ModalContent/index.tsx
import kebabCase from 'lodash.kebabcase'
import React from 'react'
import { useFocusTrap } from '../../../hooks'
import { MainChildren, ModalSize } from '../shared.types'

// Component logic...

The Main Modal Component

Finally, we combine everything into the main Modal component. This handles opening/closing states and body scroll locking.

import React from 'react'
// Modal implementation...

Final Result

You can use the modal component like this:

<Modal
  ariaLabel='Modal Example'
  btnClassName="btn btn-primary"
  btnContent='Open Modal'
  title='Modal Example'
>
  <p>This is an accessible modal built with React Portals.</p>
</Modal>

Modal Example

Here's the repo in case you want to review the complete code.