Modal
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 { 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.
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.
- 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.
- 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> </> ); };
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.
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 theesc
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> ); };
WCAG expresses the following guidelines for focus management within a modal:
- Shift the focus into the modal when triggered.
- After the modal opens, initial focus should be set on the first focusable element in the modal.
- The focus should be “trapped” inside the dialog and must not move outside the modal until it is closed.
- 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> ); };
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.
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> </> ); };
Description
The content shown within the Modal
Type
ReactNode
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
Description
The content shown within the Modal
Type
(event: Event) => void
Description
Event handler called when the escape key is down
Type
(event: KeyboardEvent) => void
Description
Event handler called when an interaction (pointer or focus event) happens outside the bounds of the content
Type
(event: PointerDownOutsideEvent | FocusOutsideEvent) => void
Description
The content shown within the Modal
Type
(event: Event) => void
Description
Event handler called when a pointer event occurs outside the bounds of the content
Type
(event: PointerDownOutsideEvent) => void
Description
The controlled open state of the Modal
Type
boolean