Modal

Jump to Props

A modal is a pop-up window that overlays the current page and requires the user to interact with it before returning to the underlying page.

Import

import { Modal } from "@vitality-ds/components";

Modals are different from Dialogs in that they only provide the show/hide functionality. If you're interested in the inner content of Modals, check out the Dialog page.

Usage

A modal is a user interface element that temporarily halts the main program flow and brings an additional window to the forefront, which demands user attention and interaction before continuing with the underlying page.

Although modals are a highly effective tool for pulling the user's attention to a single task, they are inherently disruptive to regular work and should be saved for critical tasks or information.

Aim For
  • Using Modals to capture a user's attention for critical workflows that require immediate execution.
  • Ensure that the content of the modal is both brief and easy to understand.
Avoid
  • Using modals for routine workflows or displaying non-essential information, as this can diminish their impact. Instead, reserve their use for critical tasks or presenting crucial information to users.

Wrap the <Modal></Modal> around what ever content you want displayed.

() => {
  const [open, setOpen] = React.useState(false);

  function escapeModal() {
    setOpen(false);
  }

  return (
    <>
      <Button onClick={() => setOpen(true)} appearance="primary">
        Open Modal
      </Button>
      <Modal
        open={open}
        onEscapeKeyDown={() => escapeModal()}
        onPointerDownOutside={() => escapeModal()}
        onInteractOutside={() => escapeModal()}
      >
        <div
          style={{
            backgroundColor: "var(--vitality-colors-primary1)",
            padding: "20px",
          }}
        >
          <Stack>
            <Typography variant="pageTitle">Sample Modal Content</Typography>
            <Typography>
              A modal's content has no default style - you can add your
              component here or a
              <Link target="_blank" href="../dialog">
                Vitality Dialog
              </Link>
            </Typography>
            <div style={{ width: "100%" }}>
              <Stack align="end">
                <Button onClick={() => setOpen(false)} appearance="critical">
                  Close Modal
                </Button>
              </Stack>
            </div>
          </Stack>
        </div>
      </Modal>
    </>
  );
};

Triggering the modal

The Vitality modal has been designed as a controlled component. This means that your application should manage the state of the modal, controlling when it should be open or closed, and providing the boolean state into the open prop.

Closing the modal

The Modal component has three props that allow users to close it by interacting outside of the content area:

  • onEscapeKeyDown: Triggers the closing function when the user presses the esc key while the modal is open.
  • onPointerDownOutside: Triggers the closing function when the user clicks anywhere on the background outside the modal content.
  • onInteractOutside: Triggers the closing function when the user interacts with objects outside of the modal contents, including focusing elements outside the content.

To use these props, you can pass them a function that will execute the closing behaviour. Each of these props also provides an event parameter that you can use in your logic if necessary.

To enhance the usability and accessibility of the modal component, it's best to enable all three methods of closing the modal. However, there are cases where restricting these options is necessary. For instance, if the modal involves a flow that doesn't save progress, it shouldn't allow easy closing options to avoid losing data. Similarly, for critical content that requires user attention before proceeding, it's important to limit closing methods to ensure the user addresses the content explicitly.

() => {
  const [isOpen, setIsOpen] = React.useState(false);
  const [exitType, setExitType] = React.useState("");

  function escapeModal() {
    setIsOpen(false);
  }

  function handleEscapeKeyDown(event) {
    console.log(event);
    setExitType("Escape key pressed");
    escapeModal();
  }

  function handlePointerDownOutside(event) {
    console.log(event);
    setExitType("Pointer down outside the modal");
    escapeModal();
  }

  function handleInteractOutside(event) {
    console.log(event);
    setExitType("Interacted outside the content");
    escapeModal();
  }

  return (
    <Stack align="center">
      <Button onClick={() => setIsOpen(true)}>Open Modal</Button>
      <Typography>Exiting action triggered: {exitType}</Typography>
      <Modal
        open={isOpen}
        onEscapeKeyDown={(event) => handleEscapeKeyDown(event)}
        onPointerDownOutside={(event) => handlePointerDownOutside(event)}
        onInteractOutside={(event) => handleInteractOutside(event)}
      >
        <div
          style={{
            backgroundColor: "var(--vitality-colors-primary1)",
            padding: "20px",
          }}
        >
          <Stack>
            <Typography variant="pageTitle">Close Me</Typography>
            <Typography>Press `esc` / click outside me</Typography>
          </Stack>
        </div>
      </Modal>
    </Stack>
  );
};

Focus Management

WCAG expresses the following guidelines for focus management within a modal:

  1. Shift the focus into the modal when triggered.
  2. After the modal opens, initial focus should be set on the first focusable element in the modal.
  3. The focus should be “trapped” inside the dialog and must not move outside the modal until it is closed.
  4. After a modal closes, focus should return to the element that invoked the modal.

Reference WCAG 2.4.3 Focus order success criterion for additional guidelines.

By default, the first interactable child element will receive focus when a Vitality modal is opened, and the document body on close given the next interaction is unknown. Often times, the first interactable element can be a close button or some other element that might not be desired. This can be customised by passing a function to these props that calls preventDefault(), then assigns focus elsewhere.

Consider best practises around accessibility for modals when changing focus. Keep the browser focus guidelines in mind, where visible focus can vary for elements like buttons depending on whether or not navigation is being controlled by the keyboard or a mouse (and styling changes for :focus vs :focus-visible). For example, pressing escape after opening the below modal or selected close with the space bar, will demonstrate visible focus on the 'Focused on close' button, whereas clicking out of the modal with a mouse will not, even though that is the focused element on close every time.

() => {
  const contentStyles = {
    padding: "20px",
    background: "var(--vitality-colors-primary1)",
    width: "100%",
    display: "flex",
    flexDirection: "column",
    gap: "8px",
    borderRadius: "8px",
  };

  const secondInputRef = React.useRef();
  const outsideModalRef = React.useRef();
  const [open, setOpen] = React.useState(false);

  const escapeModal = () => setOpen(false);

  const onOpenAutoFocus = (event) => {
    event.preventDefault();
    secondInputRef.current.focus();
  };

  const onCloseAutoFocus = (event) => {
    event.preventDefault();
    outsideModalRef.current.focus();
  };

  return (
    <Stack justify="center" align="stretch">
      <Button appearance="primary" onClick={() => setOpen(true)}>
        Open Modal
      </Button>
      <Modal
        open={open}
        onEscapeKeyDown={escapeModal}
        onPointerDownOutside={escapeModal}
        onInteractOutside={escapeModal}
        onOpenAutoFocus={onOpenAutoFocus}
        onCloseAutoFocus={onCloseAutoFocus}
      >
        <div style={contentStyles}>
          <Typography>The second input will receive focus</Typography>
          <FormField type="text" label="First input" name="" value="" />
          <FormField
            type="text"
            label="Second input"
            name=""
            value=""
            inputProps={{ ref: secondInputRef }}
          />
          <Stack direction="horizontal" align="center" justify="end">
            <Button onClick={escapeModal}>Exit</Button>
          </Stack>
        </div>
      </Modal>
      <Button ref={outsideModalRef} children="Focused on close" />
    </Stack>
  );
};

Vertical Alignment

All modals position their content in the vertical centre of the screen. On mobile resolutions (below @bp1), they are docked to the bottom of the screen to give more of a native experience. If the modal has elements that cause the height to grow (for example, the user interacting with content that adds new inputs) it will grow outwards from the centre point.

Displaying Modal on Modal

Whilst layered modals should be avoided, there are some scenarios where it's necessary. As an example, a feature that is accessed via a modal (usually within a Dialog component) may also present the user with a confirmation dialog prior to saving or closing. To display a Modal on another Modal, simply pass the isLayeredModal prop to the second one, which will shift the z-index for both the blanket and content.

Handling the close actions – as the Modal component is a controlled component, you have control over the functions that are run when various interactions occur (pressing escape, closing, clicking outside etc). The below example shows a simple working demo.

() => {
  const [open, setOpen] = React.useState(false);
  const [open2, setOpen2] = React.useState(false);

  function escapeModal(isLayeredModal) {
    if (isLayeredModal) {
      setOpen2(false);
      return;
    }
    setOpen(false);
  }

  return (
    <>
      <Button onClick={() => setOpen(true)} appearance="primary">
        Open Modal 1
      </Button>
      <Modal
        open={open}
        onEscapeKeyDown={() => escapeModal()}
        onPointerDownOutside={() => escapeModal()}
        onInteractOutside={() => escapeModal()}
      >
        <div
          style={{
            backgroundColor: "var(--vitality-colors-primary1)",
            padding: "20px",
          }}
        >
          <Stack>
            <Typography variant="pageTitle">Modal Layer 1</Typography>
            <Typography>
              This content is displayed in the first layer of the modal. Click
              the button below to display the second layer.
            </Typography>
            <Button onClick={() => setOpen2(true)} appearance="primary">
              Open Modal 2
            </Button>
            <Modal
              isLayeredModal
              open={open2}
              onEscapeKeyDown={() => escapeModal(true)}
              onPointerDownOutside={() => escapeModal(true)}
              onInteractOutside={() => escapeModal(true)}
            >
              <div
                style={{
                  backgroundColor: "var(--vitality-colors-primary1)",
                  padding: "20px",
                }}
              >
                <Stack>
                  <Typography variant="pageTitle">Second Modal</Typography>
                  <Typography>
                    This is the second modal. A common use case for this is when
                    you have a confirm dialog appearing above another feature
                    that's appeared in a modal. Generally, these layered modals
                    should be avoided.
                  </Typography>
                  <div style={{ width: "100%" }}>
                    <Stack align="end">
                      <Button
                        onClick={() => setOpen2(false)}
                        appearance="critical"
                      >
                        Close Modal 2
                      </Button>
                    </Stack>
                  </div>
                </Stack>
              </div>
            </Modal>
            <div style={{ width: "100%" }}>
              <Stack align="end">
                <Button onClick={() => setOpen(false)} appearance="critical">
                  Close Modal 1
                </Button>
              </Stack>
            </div>
          </Stack>
        </div>
      </Modal>
    </>
  );
};

Figma Library

Figma.logo

Props

childrenRequired

Description

The content shown within the Modal

Type

ReactNode

isLayeredModal

Description

Indicates whether or not the Modal is layered on top of another one. Shifts the zIndicies of the blanket and modal content

Type

boolean

onCloseAutoFocus

Description

The content shown within the Modal

Type

(event: Event) => void

onEscapeKeyDown

Description

Event handler called when the escape key is down

Type

(event: KeyboardEvent) => void

onInteractOutside

Description

Event handler called when an interaction (pointer or focus event) happens outside the bounds of the content

Type

(event: PointerDownOutsideEvent | FocusOutsideEvent) => void

onOpenAutoFocus

Description

The content shown within the Modal

Type

(event: Event) => void

onPointerDownOutside

Description

Event handler called when a pointer event occurs outside the bounds of the content

Type

(event: PointerDownOutsideEvent) => void

openRequired

Description

The controlled open state of the Modal

Type

boolean

© 2025