Modals are dialogs displayed over inert content that allow users to complete a singular, focused task without leaving the page.

Modals are used occasionally in our application, when the user is required to make choices before they can proceed. Here are some examples:

  • Confirm action. For example, it should be easy to delete campaigns or emails, but we need to carefully check that it's not a misclick. A modal demands a decision before deleting.

  • In context, but focused. Some tasks need UI which doesn't fit in-line, but is not big enough to have its own page. A modal is a good choice for focusing here. Examples include adding a new email, or setting the split for an A/B test.

  • Feature moments. Exporting data or starting a campaign are small API actions that we want to celebrate with animation and flair. A modal is a great way to do this, when used sparingly.

Some guidelines for using modals:

  • Make the next step clear. Even if it's just a "Got it!" button at the bottom, there needs to be an obvious action for the user. If not, why is it a modal?

  • Consistency through reuse. We have a delete-button modal in our application, which should be used instead of a custom modal when deleting something. Look for a modal you can reuse, or at least use the same design.

  • Consider popups. Modals interrupt the UI flow, make it hard to multi-task, and aren't routable. If the interaction doesn't fit one of the above three categories, consider a click button popup instead.

A Modal consists of several components:

  • Modal
    • this component renders an overlay, and the modal dialog itself.
  • ModalHeader
    • contains the modal's title and close button. Renders automatically from the title prop.
  • ModalTitle
    • the title of the modal, which renders inside the header.
  • ModalBody
    • the main content of the modal. If the modal overflows, this is the part that will scroll by default.
  • ModalFooter
    • container for the modal's footer, which typically contains buttons.
  • ModalInset
    • an optional component that may be used to render content all the way to the modal's edges (over the padding).

Importing

The components can be imported via:

import {
	Modal,
	ModalBody,
	ModalFooter,
	ModalInset,
} from '@customerio/pluma-components/react';

Usage

At it's simplest, a Modal will require:

  • isOpen prop
    • controls whether the modal is rendered or not.
  • onClose prop
    • a function that will be called when the modal requests to be closed - for example, when the close button is clicked. In this callback, you'll need to set your isOpen state accordingly.
  • title prop
    • a text string to render as the title of the modal.
  • content, wrapped in ModalBody
    • ModalBody is required for the body to handle overflow scrolling correctly.
  • a ModalFooter, with buttons or other actions
    • if there are no actions, the footer can be omitted.
import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title="Example Modal"
		>
			<ModalBody>
				<Paragraph>
					This is the body of the modal.
				</Paragraph>
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Open/close callbacks

The modal component accepts a few callbacks related to its open state:

  • onClose - this is called when the modal should be closed (for example, when the "close" "X" button is clicked)
    • the developer should use this callback to set the isOpen state to false - otherwise, the modal will stay open
  • onOpen - this is called when the modal first renders into the DOM
    • because the isOpen state is controlled from outside the modal, this callback wouldn't be used to set isOpen. Instead, this can be used to react to a state change from closed to opened if isOpen is controlled by another parent component in the app
  • onCloseComplete - when a modal closes, it will animate out, and unmount after the animation. This is called when the modal fully unmounts, after the animation is finished

For example, open devtools and see the console logs on the below modal:

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onOpen={() => {
				console.log('onOpen');
			}}
			onClose={() => {
				console.log('onClose');
				setIsOpen(false);
			}}
			onCloseComplete={() => {
				console.log('onCloseComplete');
			}}
			title="Example Modal"
			animationTransitionDuration={500}
		>
			<ModalBody>
				<Paragraph>
					This is the body of the modal.
				</Paragraph>
			</ModalBody>
		</Modal>
	</>
}

Dismissable modals

By default, modals are dismissable and will close when:

  • the "close" button is clicked
  • the overlay is clicked
  • the "Escape" key is pressed

Depending on the use case, it may be necessary to disable one or more of these behaviors. Each of the dismissals can be controlled via a prop:

  • shouldShowCloseButton
    • if false, the close button in the header won't be rendered.
  • shouldCloseOnOverlayClick
    • if false, clicking the overlay won't close the modal.
  • shouldCloseOnEscapePress
    • if false, pressing the "Escape" key won't close the modal.

Additionally, there is an isDismissable prop, which is a shortcut for disabling all of the above.

Note: The individual props, if set, will still take precedence over the isDismissable setting.

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			title="Can't dismiss"
			isDismissable={false}
		>
			<ModalBody>
				<Paragraph>
					I can only be dismissed with the button below.
				</Paragraph>
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Size

If you have a small amount of content, you can use the size prop to make the modal smaller.

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title="Small Modal"
			size="sm"
		>
			<ModalBody>
				<Paragraph>
					I'm a small modal.
				</Paragraph>
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Scroll behavior

A modal will stretch vertically to accommodate its content, up to a certain maximum height. After that, the ModalBody will become scrollable.

Try opening the below modal and resizing your browser to make it shorter:

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title={longFormTitle}
		>
			<ModalBody>
				{longFormContent}
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Alternatively, it's possible to set the scrolling behavior so that the entire viewport scrolls instead. This is done by setting the shouldScrollInViewport prop to true:

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title={longFormTitle}
			shouldScrollInViewport={true}
		>
			<ModalBody>
				{longFormContent}
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Custom content

By default ModalBody contains padding to keep the content from the edges of the modal. In some cases, it may be necessary for the content to stretch all the way to the edges - for example, when showing a full-width image or table.

To make this possible, the ModalInset component can be used:

import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title="Stretchy content"
		>
			<ModalBody>
				<ModalInset backgroundColor="accent-minimal" p="100">
					<Paragraph>I stretch all the way to the edges</Paragraph>
				</ModalInset>

				<Box backgroundColor="information-minimal" p="100">
					<Paragraph>I don't stretch</Paragraph>
				</Box>
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}
import { Button, Paragraph, Modal, ModalBody, ModalFooter } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);

	return <>
		<Button
			variant="primary"
			onClick={() => setIsOpen(true)}
		>
			Open Table Modal
		</Button>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title="Stretchy content"
		>
			<ModalBody>
				<ModalInset>
					<Table caption="Australian birds">
						<TableThead>
							<TableTr>
								<TableTh>Name</TableTh>
								<TableTh>Genus</TableTh>
								<TableTh>Species</TableTh>
							</TableTr>
						</TableThead>

						<TableTbody>
							<TableTr>
								<TableTd>Australian Magpie</TableTd>
								<TableTd>Gymnorhina</TableTd>
								<TableTd>tibicen</TableTd>
							</TableTr>

							<TableTr>
								<TableTd>Galah</TableTd>
								<TableTd>Eolophus</TableTd>
								<TableTd>roseicapilla</TableTd>
							</TableTr>

							<TableTr>
								<TableTd>Rainbow lorikeet</TableTd>
								<TableTd>Trichoglossus</TableTd>
								<TableTd>moluccanus</TableTd>
							</TableTr>
						</TableTbody>
					</Table>
				</ModalInset>
			</ModalBody>

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Variants

Split

In some cases, we want to make a modal more graphic and include an image, content with a visual background, or more. The split modal variant splits the main modal content into two halves vertically, and renders the header, close button, body, and footer on the right side, and leaves the left side free for any visual content. The left half goes from top to bottom and has no padding.

To make this possible, use ModalSplit as a child of Modal. Placing ModalSplit before ModalBody will place the graphic content on the left, and placing it after will place the graphic content on the right.

import { Button, Paragraph, Modal, ModalBody, ModalFooter, ModalSplit, Box, ButtonGroup } from '@customerio/pluma-components/react';
import { useState } from 'react';

export default function Example() {
	const [isOpen, setIsOpen] = useState(false);
	const [direction, setDirection] = useState('left');
	const background = 'linear-gradient(42deg,rgba(42, 123, 155, 1) 0%, rgba(87, 199, 133, 1) 50%, rgba(237, 221, 83, 1) 100%)';

	return <>
		<ButtonGroup groupVariant="spaced">
			<Button
				variant="primary"
				onClick={() => {
					setIsOpen(true);
					setDirection('left');
				}}
			>
				Split left
			</Button>
			<Button
				variant="primary"
				onClick={() => {
					setIsOpen(true);
					setDirection('right');
				}}
			>
				Split right
			</Button>
		</ButtonGroup>

		<Modal
			isOpen={isOpen}
			onClose={() => setIsOpen(false)}
			title="Example Modal"
		>
			{direction === 'left' ? (<ModalSplit>
				<Box position='absolute' style={{ background, top: 0, right: 0, bottom: 0, left: 0 }} />
			</ModalSplit>) : null}

			<ModalBody>
				<Paragraph>
					This is the body of the modal.
				</Paragraph>
			</ModalBody>

			{direction === 'right' ? (<ModalSplit>
				<Box position='absolute' style={{ background, top: 0, right: 0, bottom: 0, left: 0 }} />
			</ModalSplit>) : null}

			<ModalFooter>
				<Button
					onClick={() => setIsOpen(false)}
				>
					Close
				</Button>
			</ModalFooter>
		</Modal>
	</>
}

Accessibility

To make modal's accessible, the component contains the following features:

  • When a modal is opened, focus moves to the first focusable element
  • When the modal is closed, focus returns to the element that opened it
  • Modals include a focus trap - tabbing through the modal's elements will restrict focus to its contents only.

API

PlumaModal extends Box

Default:125

The duration (in ms) for the modal animation.

By default, the modal will focus the first focusable element. You can override this by setting the index of the focusable element you want to focus instead.

A shortcut to set shouldShowCloseButton, shouldCloseOnOverlayClick, and shouldCloseOnEscapePress to false. The other prop values still take precedence, if set, even when isDismissable is true.

Whether the modal is open.

Called when the modal is requesting to be closed. For example, when clicking the close button, clicking the overlay, or pressing the Escape key.

Called when the modal finishes closing (after the close animation finishes).

Called when the modal is opened.

Whether onClose should be called when the Escape key is pressed.

Whether onClose should be called when the overlay is clicked.

When the modal's content is long and causing overflows, this controls whether the whole modal should scroll inside the viewport, rather than the modal's body being the scroll container.

Whether a close button should be displayed in the modal.

The size of the modal.

A special prop containing all the other modal props for use with the ModalsManager.

The title of the modal. If none is provided, the default ModalHeader will not render.