Building Accessible Modals in React with Bootstrap and Portals

March 10, 2021 · 7 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 since I last posted anything. I've been busy lately, working a lot with React using TypeScript, along with some Prisma and NestJS on the backend. Now, let’s get started.

I'm not a huge fan of reactstrap or react-bootstrap because I enjoy building things manually. So, I decided to implement the core modal functionality (almost) by myself—excluding styles, which are provided via Bootstrap—while also making it accessible and using modern React features like hooks and portals.

First, let’s create a new React project using TypeScript (not required, but it helps with better typing):

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

After that, install the required dependencies:

yarn add bootstrap@next node-sass

Now, rename the index.css file to main.scss, remove all its content, and add this line:

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

Remember to update the index.tsx import to match the new file. Then, leave your App.tsx like this:

import React from 'react';

function App() {
  return (
    <div className="App">
      <h1>Bootstrap Modal Example</h1>
    </div>
  );
}

export default App;

With that set up, create a components/Modal folder where all modal-related logic will live.

First, create a React Portal that will act as the overlay shadow when the modal is open:

// 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,
  )
}

Next, create the component responsible for opening the modal: ModalButton. This will be a regular button that also receives a reference so we can access the DOM element from the parent modal component. Let’s define the shared types first.

// 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'
  • BtnRef is used to hold a DOM reference and supports multiple typing scenarios for useRef.
  • MainChildren allows either a ReactNode or a callback function, useful for programmatically closing the modal from children.
  • ModalSize matches Bootstrap modal size options.

Now define the props for ModalButton:

// components/Modal/ModalButton/ModalButton.interfaces.ts
import React from 'react'

import { BtnRef } from '../shared.types';

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

As you can see, the component extends standard button props and adds a custom reference prop. Now create the component:

// components/Modal/ModalButton/index.tsx
import React from 'react'

import {Props} from './ModalButton.interfaces'

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

This simply attaches the ref and spreads the rest of the button props. Pretty nice—this pattern is very useful for building reusable components.

Now let’s build the modal content component, which will contain all modal-related UI. First, define the props:

// components/Modal/ModalContent/ModalContent.interfaces.ts
import React from 'react'

import { BtnRef, MainChildren, ModalSize } from '../shared.types'

export interface Props {
  ariaLabel?: string
  buttonRef: BtnRef
  center: boolean
  footerChildren?: MainChildren
  open: boolean
  mainChildren: MainChildren
  modalRef: React.RefObject<HTMLDivElement>
  onClickAway: (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
  onClose: () => void
  onKeyDown: ((event: React.KeyboardEvent<HTMLDivElement>) => void) | undefined
  size: ModalSize
  scrollable: boolean
  title?: string
}

Some of these props handle closing logic, while others are for styling. mainChildren and footerChildren can be either a ReactNode or a function returning one. We also use BtnRef for the close (X) button.

Now, create a hook to trap focus within the modal when it is 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
}

Install the kebab-case utility for aria-labelledby:

yarn add lodash.kebabcase

yarn add -D @types/lodash.kebabcase

Now create the ModalContent component:

// components/Modal/ModalContent/index.tsx
import kebabCase from 'lodash.kebabcase'
import React from 'react'

import { useFocusTrap } from '../../../hooks'

import { MainChildren } from '../shared.types'

import { Props } from './ModalContent.interfaces'

const TIMEOUT_VALUE = 300

export default function ModalContent({
  ariaLabel,
  buttonRef,
  center,
  footerChildren,
  mainChildren,
  modalRef,
  onClickAway,
  onClose,
  onKeyDown,
  open,
  size,
  scrollable,
  staticBackdrop,
  title,
}: Props) {
  const [staticAnimation, setStaticAnimation] = React.useState(false)
  const [staticClass, setStaticClass] = React.useState('')
  const [openClass, setOpenClass] = React.useState('')
  const dialogRef = useFocusTrap()
  const scrollClass = scrollable ? ' modal-dialog-scrollable' : ''
  const verticalCenterClass = center ? ' modal-dialog-centered' : ''

  React.useEffect(() => {
    const timer = setTimeout(() => {
      setOpenClass(open ? ' show' : '')
    }, TIMEOUT_VALUE);
    return () => clearTimeout(timer);
  }, [open]);

  React.useEffect(() => {
    const timer = setTimeout(() => {
      setStaticClass(staticAnimation ? ' modal-static' : '')
    }, TIMEOUT_VALUE);
    return () => clearTimeout(timer);
  }, [staticAnimation]);

  const staticOnClick = () => setStaticAnimation(!staticAnimation)

  const render = (content: MainChildren) =>
    typeof content === 'function' ? content(onClose) : content

  return (
    <div
      ref={dialogRef}
      className={`modal fade${staticClass}${openClass}`}
      aria-labelledby={kebabCase(ariaLabel)}
      tabIndex={-1}
      onClick={staticBackdrop ? staticOnClick : onClickAway}
      onKeyDown={onKeyDown}
      style={{
        display: open ? 'block' : 'none',
        ...(openClass && {paddingRight: '15px'}),
        ...(staticAnimation && {overflow: 'hidden'})
      }}
      {...(open ? {'aria-modal': true, role: 'dialog'} : {'aria-hidden': true})}
    >
      <div
        className={`modal-dialog modal-${size}${scrollClass}${verticalCenterClass}`}
        ref={modalRef}
      >
        <div className='modal-content'>
          <div className='modal-header'>
            {title && <h5>{title}</h5>}
            <button
              type='button'
              className='btn-close'
              aria-label='close-modal'
              onClick={onClose}
              ref={buttonRef}
            />
          </div>
          <div className='modal-body'>{render(mainChildren)}</div>
          {footerChildren && (
            <div className='modal-footer'>{render(footerChildren)}</div>
          )}
        </div>
      </div>
    </div>
  )
}

This component always includes a header with a close button (X). The render helper supports both function and node children. The effects replicate Bootstrap animations (partially).

Now create the main Modal component:

import React from 'react'

import { Props } from './Modal.interfaces'
import ModalContent from './ModalContent'
import ModalOverlay from './ModalOverlay'
import ModalButton from './ModalButton'

export default function Modal({
  ariaLabel,
  btnClassName,
  btnContent,
  center = false,
  children,
  footerChildren,
  size = 'lg',
  scrollable,
  title,
}: Props) {
  const [open, setOpen] = React.useState(false)
  const btnOpenRef = React.useRef<HTMLButtonElement>(null)
  const btnCloseRef = React.useRef<HTMLButtonElement>(null)
  const modalNode = React.useRef<HTMLDivElement>(null)
  const ESCAPE_KEY = 'Escape'

  React.useEffect(() => {
    if (open) {
      btnCloseRef.current!.focus()
    } else {
      btnOpenRef.current!.focus()
    }
  }, [open])

  function toggleScrollLock() {
    document.querySelector('body')!.classList.toggle('modal-open')
  }

  const onOpen = () => {
    setOpen(true)
    toggleScrollLock()
  }

  const onClose = () => {
    setOpen(false)
    toggleScrollLock()
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key === ESCAPE_KEY) {
      onClose()
    }
  }

  const onClickAway = (event: any) => {
    if (modalNode.current && !modalNode.current.contains(event.target)) {
      onClose()
    }
  }

  return (
    <>
      <ModalContent
        ariaLabel={ariaLabel}
        buttonRef={btnCloseRef}
        center={center}
        footerChildren={footerChildren}
        open={open}
        mainChildren={children}
        modalRef={modalNode}
        onClickAway={onClickAway}
        onClose={onClose}
        onKeyDown={onKeyDown}
        size={size}
        scrollable={scrollable}
        title={title}
      />
      {open && <ModalOverlay />}

      <ModalButton
        onClick={onOpen}
        className={btnClassName}
        buttonRef={btnOpenRef}
      >
        {btnContent}
      </ModalButton>
    </>
  )
}

This component manages the modal state, handles events, and controls focus behavior. It also locks body scroll when the modal is open.

Finally, use the modal like this:

import React from 'react'

import Modal from './components/Modal'

function App() {
  return (
    <div className="container">
      <h1>Bootstrap Modal Example</h1>
      <Modal
        ariaLabel='Modal Example'
        btnClassName="btn btn-primary"
        btnContent='Modal regular'
        footerChildren={(closeModal) => (
            <button
              type='button'
              className='btn btn-primary'
              onClick={closeModal}
            >
              Close it from the child
            </button>
        )}
        title='Modal Example regular'
      >
        <p>This is a regular Modal</p>
      </Modal>
      <Modal
        ariaLabel='Modal Example lg'
        btnClassName="btn btn-secondary"
        btnContent='Modal lg'
        size='lg'
        footerChildren={(closeModal) => (
            <button
              type='button'
              className='btn btn-primary'
              onClick={closeModal}
            >
              Close it from the child
            </button>
        )}
        title='Modal Example lg'
      >
        <p>This is a large Modal</p>
      </Modal>
    </div>
  )
}

As you can see, you can pass either a ReactNode or a (closeModal: () => void) => ReactNode for both the footer and main content. This allows children to control modal behavior, which is especially useful for forms.

Hope you enjoyed the post. Converting this to JavaScript should be straightforward—the idea remains the same. This approach ensures proper focus handling within the modal.

Modal Example

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

Helpful? /