Components

Modal

Modals are used to overlay content above an interface. They are intended to capture the user's attention in order to inform or shift focus to a pertinent task.

New Modal: We've released Modal V2 with improved accessibility, better TypeScript support, and modern React patterns. The legacy Modal (V1) is deprecated and will be removed in a future version. See the Usage tab for migration guidance.

Modal V2 provides comprehensive accessibility features, keyboard navigation, and focus management out of the box. It supports both controlled and uncontrolled state modes, and includes responsive bottom sheet layout on mobile devices.

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Assign Chatbot"
>
<ModalHeader
title="Assign Chatbot"
subtitle="The chatbot will respond to customers from this profile."
/>
<ModalBody>
<Text fontSize={300} color="text.body">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem
ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Lorem ipsum
dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
incididunt ut labore et dolore magna aliqua.
</Text>
</ModalBody>
<ModalFooter
cancelButton={<Button appearance="unstyled">Go back</Button>}
primaryButton={<Button appearance="primary" px={500}>Submit</Button>}
/>
</Modal>
</Box>
)
}

Controlled Modal

For controlled modals, use the open and onOpenChange props instead of modalTrigger:

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
const [isOpen, setIsOpen] = useState(false);
return (
<Box>
<Button appearance="primary" onClick={() => setIsOpen(true)}>
Open controlled modal
</Button>
<Modal
open={isOpen}
onOpenChange={setIsOpen}
aria-label="Controlled Modal Example"
>
<ModalHeader
title="Controlled Modal"
subtitle="This modal's state is managed by the parent component."
/>
<ModalBody>
<Text fontSize={300} color="text.body">
The modal is controlled via the `open` and `onOpenChange` props.
Click the button outside the modal or use the close button to close it.
</Text>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}

Modal V2 consists of several components that work together:

The main Modal component that wraps all modal content. It handles accessibility, focus management, and state.

Note: This component extends Radix UI Dialog primitives to provide accessible dialog functionality.

NameTypeDefaultDescriptionRequired?
children
ReactNode
Modal content. Typically includes ModalHeader, ModalBody, and ModalFooter components.
modalTrigger
ReactElement
React element (typically a Button) that triggers the modal when clicked. Used for uncontrolled modals.
open
boolean
Controls whether the modal is open (controlled mode). Use with onOpenChange.
defaultOpen
boolean
Default open state for uncontrolled mode. Use with modalTrigger.
onOpenChange
(open: boolean) => void
Callback fired when the open state changes. Receives the new open state.
aria-label
string
Accessible label for the modal dialog. Required for screen readers.
title
string
Modal title. Automatically creates a ModalHeader if provided.
subtitle
string
Modal subtitle. Automatically creates a ModalHeader if provided.
description
string
Modal description. Automatically wrapped in ModalDescription for accessibility.
draggable
boolean
Enable draggable functionality. When true, the modal header becomes a drag handle.
showOverlay
boolean
Whether to show the background overlay. Defaults to true.
actions
Array<ModalActionConfig>
Array of action configurations for the floating action rail.
closeButtonAriaLabel
string
Accessible label for the close button. Defaults to 'Close'.
data
Record<string, any>
Additional data attributes to pass to the modal content.

ModalHeader

Renders the modal's header with title and optional subtitle. Automatically created when title or subtitle props are provided to the root Modal component.

Note: This component extends Radix UI Dialog.Title and Dialog.Description primitives for accessible header content. For custom header layouts with complex content or actions, use ModalCustomHeader instead.

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with header</Button>}
aria-label="Example Modal"
>
<ModalHeader
title="Modal Title"
subtitle="This is a subtitle that provides additional context"
/>
<ModalBody>
<Text fontSize={300} color="text.body">
Modal content goes here. The header above was created using the ModalHeader component.
</Text>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
title
string
Modal title text displayed as a headline
subtitle
string
Modal subtitle text. Automatically wrapped in Dialog.Description for accessibility.
titleProps
Omit< React.ComponentPropsWithoutRef<typeof Dialog.Title>, "asChild" | "children" >
Additional props for Dialog.Title when title is provided
subtitleProps
Omit< React.ComponentPropsWithoutRef<typeof Dialog.Description>, "asChild" | "children" >
Additional props for Dialog.Description when subtitle is provided

ModalBody

Renders the scrollable main content area of the modal between the header and footer.

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with body content</Button>}
aria-label="Example Modal"
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body" mb={400}>
This is the modal body content. It can contain any content you need.
</Text>
<Box bg="neutral.200" p={400} borderRadius={400}>
<Text fontSize={200} color="text.body">
ModalBody accepts all Box props for styling, so you can customize
the layout, spacing, and appearance as needed.
</Text>
</Box>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
children
React.ReactNode
The main content of the modal body

ModalFooter

Provides automatic button wrapping and layout management. Buttons are automatically wrapped in ModalCloseWrapper to close the modal when clicked.

Note: For custom footer layouts with informational text or complex layouts, use ModalCustomFooter instead.

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with footer</Button>}
aria-label="Example Modal"
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body">
Modal content goes here.
</Text>
</ModalBody>
<ModalFooter
cancelButton={<Button appearance="unstyled">Cancel</Button>}
primaryButton={<Button appearance="primary">Save</Button>}
/>
</Modal>
</Box>
)
}

Footer with left action:

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with left action</Button>}
aria-label="Delete Item"
>
<ModalHeader title="Delete Item" />
<ModalBody>
<Text fontSize={300} color="text.body">
Are you sure you want to delete this item?
</Text>
</ModalBody>
<ModalFooter
leftAction={<Button appearance="destructive">Delete</Button>}
cancelButton={<Button appearance="unstyled">Cancel</Button>}
primaryButton={<Button appearance="primary">Save</Button>}
/>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
primaryButton
ReactNode
Primary action button - automatically wrapped in ModalCloseWrapper to close the modal. At least one action (primaryButton, cancelButton, or leftAction) must be provided.
cancelButton
ReactNode
Cancel/secondary button - automatically wrapped in ModalCloseWrapper to close the modal.
leftAction
ReactNode
Optional action on the far left (e.g., Delete button) - NOT automatically wrapped in ModalCloseWrapper. Use for destructive actions that don't close the modal.

ModalCloseWrapper

Wraps elements (typically buttons) to automatically close the modal when clicked. This component is primarily used for custom implementations, such as when creating a custom ModalFooter or custom header/footer layouts. ModalFooter automatically wraps its buttons in ModalCloseWrapper, so you typically only need to use this component directly when building custom footer or header implementations.

Note: This component extends Radix UI Dialog.Close primitive to provide accessible close functionality.

import { Modal, ModalHeader, ModalBody, ModalCloseWrapper, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with custom close wrapper</Button>}
aria-label="Example Modal"
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body" mb={400}>
This modal uses ModalCloseWrapper to wrap custom buttons that close the modal.
</Text>
<Box display="flex" gap={300}>
<ModalCloseWrapper>
<Button appearance="unstyled">Custom Close Button</Button>
</ModalCloseWrapper>
<ModalCloseWrapper>
<Button appearance="secondary">Another Close Button</Button>
</ModalCloseWrapper>
</Box>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Save</Button>}
/>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
children
React.ReactNode
The element to wrap with close functionality
onClick
(e: React.MouseEvent) => void
Optional click handler called before closing the modal
asChild
boolean
Whether to merge props into the child element (default: true)

ModalCloseWrapper does not accept system props. It only accepts children, onClick, and asChild props.

Actions Prop

The actions prop on the Modal component allows you to add quick action buttons to a floating rail alongside the modal. These actions appear in a vertical rail next to the modal (horizontal on mobile) and provide quick access to common actions like close, expand, etc.

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with custom actions</Button>}
aria-label="Example Modal"
actions={[
{
actionType: "button",
iconName: "arrows-pointing-out",
"aria-label": "Expand modal",
onClick: () => console.log("Expand clicked"),
},
]}
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body">
This modal includes custom actions in the floating rail. The close button
is automatically included, and we've added an expand action using the `actions` prop.
</Text>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}

Action Configuration:

Each action in the actions array accepts:

  • actionType: "close" (automatically closes modal) or "button" (default, requires onClick)
  • iconName: Icon name from the Seeds icon set
  • aria-label: Required accessible label for the action
  • onClick: Optional click handler (ignored for actionType: "close")

Customizing the Close Button

The default floating close button can be customized in two ways:

Simple customization - Use closeButtonAriaLabel to customize just the accessible label:

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
closeButtonAriaLabel="Close this dialog"
aria-label="Example Modal"
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body">
The close button has a custom aria-label.
</Text>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}

Advanced customization - Use closeButtonProps to customize the close button with additional props like onClick, id, disabled, className, etc:

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
const [clickCount, setClickCount] = useState(0);
return (
<Box>
<Text fontSize={200} color="text.body" mb={400}>
Close button clicked {clickCount} times
</Text>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Example Modal"
closeButtonProps={{
"aria-label": "Close this dialog",
onClick: () => {
console.log("Close button clicked!");
setClickCount(prev => prev + 1);
},
id: "custom-close-btn",
}}
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body">
The close button has custom onClick, id, and aria-label props.
Click the close button to see the counter increment.
</Text>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close</Button>}
/>
</Modal>
</Box>
)
}

Note: You must provide an accessible label through either closeButtonAriaLabel or closeButtonProps["aria-label"]. TypeScript will enforce this requirement.

ModalCustomHeader

For custom headers with complex layouts or actions, use ModalCustomHeader instead of the simple title/subtitle props.

import { Modal, ModalCustomHeader, ModalBody, ModalCloseWrapper } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with custom header</Button>}
aria-label="Example Modal"
>
<ModalCustomHeader>
<Box display="flex" alignItems="center" justifyContent="space-between" width={1}>
<Box>
<Text fontSize={400} fontWeight="bold" color="text.headline">
Custom Header
</Text>
<Text fontSize={200} color="text.subtext">
With custom layout and actions
</Text>
</Box>
<Box display="flex" gap={300}>
<ModalCloseWrapper>
<Button appearance="unstyled">Cancel</Button>
</ModalCloseWrapper>
<ModalCloseWrapper>
<Button appearance="primary">Save</Button>
</ModalCloseWrapper>
</Box>
</Box>
</ModalCustomHeader>
<ModalBody>
<Text fontSize={300} color="text.body">
Modal content goes here.
</Text>
</ModalBody>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
children
ReactNode
Content to render inside the custom header
draggable
boolean
Internal prop for draggable functionality (automatically set when Modal is draggable)
isDragging
boolean
Internal prop indicating if modal is currently being dragged

ModalCustomFooter

For custom footers with informational text or complex layouts, use ModalCustomFooter instead of the standard ModalFooter component.

import { Modal, ModalHeader, ModalBody, ModalCustomFooter, ModalCloseWrapper } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal with custom footer</Button>}
aria-label="Example Modal"
>
<ModalHeader title="Example Modal" />
<ModalBody>
<Text fontSize={300} color="text.body">
Modal content goes here.
</Text>
</ModalBody>
<ModalCustomFooter
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Text fontSize={200} color="text.subtext">
This is some information you may need to know!
</Text>
<Box display="flex" gap={300}>
<ModalCloseWrapper>
<Button appearance="unstyled">Cancel</Button>
</ModalCloseWrapper>
<ModalCloseWrapper>
<Button appearance="primary">Save</Button>
</ModalCloseWrapper>
</Box>
</ModalCustomFooter>
</Modal>
</Box>
)
}
NameTypeDefaultDescriptionRequired?
children
ReactNode
Content to render inside the custom footer

Recipes

Modal headers can be omitted when the first item in the modal body is an image or illustration. If the modal body begins with text, a header with a title or subtitle should be used.

The following example shows a modal without a header, which displays only a close button in the floating action rail.

import { Modal, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Example Modal"
>
<ModalBody>
<Box
mb={500}
display="flex"
alignItems="center"
flexDirection="column"
>
<Box width="400px" height="400px" bg="neutral.200" />
<Text
fontSize={400}
fontWeight="bold"
color="text.headline"
pt={600}
>
Some headline
</Text>
<Text
fontSize={300}
textAlign="center"
color="text.body"
pt={450}
px={500}
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Lorem
ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua.
</Text>
</Box>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary">Close modal</Button>}
/>
</Modal>
</Box>
)
}

Expressive modal

import { Modal, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Example Modal"
bg="blue.400"
>
<ModalBody>
<Image
alt="expressive illustration"
mb={600}
src={withPrefix('/illus.svg')}
/>
<Box
width="100%"
bg="neutral.0"
p={600}
display="flex"
flexDirection="column"
>
<Text
fontSize={400}
color="text.headline"
fontWeight={700}
textAlign="center"
pb={400}
>
Engagement makes the world go 'round.
</Text>
<Text fontSize={200} textAlign="center" color="text.body">
We care about how you engage with your customers and we're here to
make that experience as smooth and seamless as possible.
</Text>
</Box>
</ModalBody>
<ModalFooter
primaryButton={<Button appearance="primary" minWidth={120}>Thanks</Button>}
/>
</Modal>
</Box>
)
}

Destructive confirmation

import { Modal, ModalHeader, ModalBody, ModalFooter } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Delete profiles confirmation"
>
<ModalHeader
title="Delete profiles?"
subtitle="This action can not be undone."
/>
<ModalBody>
<Text as='div' py={300} fontSize={300} color="text.body">
Deleting this profile will remove it from all groups as well as
remove any messages associated with this profile. Are you sure you
want to proceed?
</Text>
</ModalBody>
<ModalFooter
cancelButton={<Button appearance="unstyled" mr={400}>Cancel</Button>}
primaryButton={<Button appearance="destructive" minWidth="120px">Confirm</Button>}
/>
</Modal>
</Box>
)
}

Free form modal

This example showcases the freedom and flexibility we have within ModalBody. For custom headers and footers, use ModalCustomHeader and ModalCustomFooter components.

import { Modal, ModalCustomHeader, ModalBody, ModalCloseWrapper } from '@sproutsocial/racine/modal/v2'
() => {
return (
<Box>
<Modal
modalTrigger={<Button appearance="primary">Open modal</Button>}
aria-label="Free form experience"
>
<ModalCustomHeader>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
width={1}
>
<Box display="flex" flexDirection="column">
<Text fontSize={400} fontWeight="bold" color="text.headline">
Free form experience
</Text>
<Text fontSize={200} color="text.subtext">
Browse and select things from the list as you please, nothing is
required.
</Text>
</Box>
<Box display="flex">
<ModalCloseWrapper>
<Button mr={350} appearance="unstyled">
Cancel
</Button>
</ModalCloseWrapper>
<ModalCloseWrapper>
<Button minWidth="120px" appearance="primary">
Complete
</Button>
</ModalCloseWrapper>
</Box>
</Box>
</ModalCustomHeader>
<ModalBody display="flex">
<Box width={1 / 2} display="flex" flexDirection="column" pr={450}>
<Text
fontWeight={700}
fontSize={300}
color="text.headline"
mb={400}
>
An informational headline
</Text>
<Text fontSize={200} color="text.body" mb={350}>
Authoritatively integrate installed base deliverables without
worldwide intellectual capital. Progressively promote functional
markets before mission-critical potentialities.
</Text>
<Text fontSize={200} color="text.body" mb={350}>
Assertively incentivize 2.0 communities with quality technologies.
Globally benchmark accurate sources for go forward systems.
Energistically develop out-of-the-box ideas and quality services.
</Text>
</Box>
<Box width={1 / 2}>
<ModalListExample />
</Box>
</ModalBody>
</Modal>
</Box>
)
}