Basics
The recommended way to use a Tray is to add to dom when visible, and use onCloseComplete to remove it.
It is also recommended to pin it to the right side of the screen on tablet and desktop, and pin to bottom with handlebar on mobile.
function BasicTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Example title"
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<Text color="fgMuted" paddingBottom={2}>
Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum
lorem id, viverra.
</Text>
</Tray>
)}
</VStack>
);
}
Pinning
While you can pin the tray to any side of the screen, it is recommended to only use pin to bottom or right. Bottom is recommended for mobile, and right is recommended for tablet and desktop.
Handlebar is only shown on bottom pinned trays, and adjusts the padding to match other pins. It is deprecated to use bottom pin without handlebar.
function PinnedTray() {
const [pinDirection, setPinDirection] = useState(null);
const { isPhone } = useBreakpoints();
const handleClose = () => setPinDirection(null);
return (
<VStack gap={2}>
<HStack gap={2} flexWrap="wrap">
<Button onClick={() => setPinDirection('right')}>Open Right Tray</Button>
<Button onClick={() => setPinDirection('bottom')}>Open Bottom Tray</Button>
<Button onClick={() => setPinDirection('left')}>Open Left Tray</Button>
<Button onClick={() => setPinDirection('top')}>Open Top Tray</Button>
</HStack>
{pinDirection !== null && (
<Tray
pin={pinDirection}
showHandleBar
onCloseComplete={handleClose}
title="Example title"
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<Text color="fgMuted" paddingBottom={2}>
Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum
lorem id, viverra.
</Text>
</Tray>
)}
</VStack>
);
}
Content
Web Tray will automatically be scrollable when the content is too large to fit. You can adjust verticalDrawerPercentageOfView to control the maximum height of the tray when pinned to the bottom or top.
When scrolling, a border is added to the header.
function ResponsiveTray() {
function ResponsiveTray({ styles, ...props }) {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<Tray
{...props}
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
styles={{
...styles,
content: {
paddingBottom: 'var(--space-3)',
...styles?.content,
},
}}
verticalDrawerPercentageOfView="90%"
/>
);
}
function Example() {
const [visible, setVisible] = useState(false);
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Scrolling Tray</Button>
{visible && (
<ResponsiveTray onCloseComplete={handleClose} title="Header">
{Array.from({ length: 20 }, (_, i) => (
<ListCell
key={i}
spacingVariant="condensed"
title="Title"
description="Description"
accessory="arrow"
onClick={() => alert('Cell clicked!')}
innerSpacing={{
marginX: -4,
paddingX: 4,
paddingY: 1,
}}
/>
))}
</ResponsiveTray>
)}
</VStack>
);
}
return <Example />;
}
You can pass in a custom node to title to render a custom header.
function IllustrationSectionHeaderTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
const titleId = useId();
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Illustration Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title={
<VStack gap={{ phone: 1.5, tablet: 2, desktop: 2 }}>
<Pictogram name="addWallet" />
<Text id={titleId} font="title3">
Section header
</Text>
</VStack>
}
accessibilityLabelledBy={titleId}
>
<Text color="fgMuted" font="body" paddingBottom={2}>
Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum
lorem id, viverra.
</Text>
</Tray>
)}
</VStack>
);
}
You can use a full bleed header with a background image. Use header and title to add a section header that stays fixed below the image while content scrolls. When scrolling, a border appears below the header area.
function FullBleedHeaderTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
const titleId = useId();
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Full Bleed Header Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title={
<Box flexGrow={1} marginX={{ base: -4, phone: -3 }}>
<img
alt="Full Bleed"
height={180}
src="/img/tray_header.png"
style={{ objectFit: 'cover', pointerEvents: 'none' }}
width="100%"
/>
</Box>
}
header={
<Text id={titleId} font="title3" paddingTop={2} paddingX={{ base: 4, phone: 3 }}>
Section header
</Text>
}
styles={{
handleBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
closeButton: {
position: 'absolute',
top: 'var(--space-4)',
right: 'var(--space-4)',
zIndex: 1,
},
header: {
paddingTop: 0,
},
content: { paddingBottom: 'var(--space-3)' },
}}
accessibilityLabelledBy={titleId}
>
<Text color="fgMuted" font="body" paddingBottom={2}>
Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum
lorem id, viverra.
</Text>
</Tray>
)}
</VStack>
);
}
When using a full bleed header with scrollable content, the header prop keeps the section header fixed while list cells scroll beneath it.
function FullBleedHeaderScrollableTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
const titleId = useId();
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Full Bleed Scrollable Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title={
<Box flexGrow={1} marginX={{ base: -4, phone: -3 }}>
<img
alt="Full Bleed"
height={180}
src="/img/tray_header.png"
style={{ objectFit: 'cover', pointerEvents: 'none' }}
width="100%"
/>
</Box>
}
header={
<Text id={titleId} font="title3" paddingTop={2} paddingX={{ base: 4, phone: 3 }}>
Section header
</Text>
}
styles={{
handleBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
closeButton: {
position: 'absolute',
top: 'var(--space-4)',
right: 'var(--space-4)',
zIndex: 1,
},
header: {
paddingTop: 0,
},
content: { paddingBottom: 'var(--space-3)' },
}}
accessibilityLabelledBy={titleId}
verticalDrawerPercentageOfView="90%"
>
{Array.from({ length: 20 }, (_, i) => (
<ListCell
key={i}
spacingVariant="condensed"
title="Title"
description="Description"
accessory="arrow"
onClick={() => alert('Cell clicked!')}
innerSpacing={{
marginX: -4,
paddingX: 4,
paddingY: 1,
}}
/>
))}
</Tray>
)}
</VStack>
);
}
Controlled
You have various ways to control the state of a tray.
Via Ref
You can use a ref to control the tray, which provides a close() method.
Accessibility tipA ref to the trigger that opens the tray, along with an onClosedComplete method to reset focus on the trigger when the tray closes, needs to be wired up for accessibility.
function TrayWithRef() {
const [visible, setVisible] = useState(false);
const trayRef = useRef(null);
const triggerRef = useRef(null);
const handleOpen = () => setVisible(true);
const handleClose = () => {
setVisible(false);
triggerRef.current?.focus();
};
return (
<VStack gap={2}>
<Button ref={triggerRef} onClick={handleOpen}>
Open Tray
</Button>
{visible && (
<Tray ref={trayRef} title="Ref Controlled Tray" onCloseComplete={handleClose} pin="right">
<VStack gap={2}>
<Text>Control this tray using the ref.</Text>
<Button onClick={() => trayRef.current?.close()}>Close</Button>
</VStack>
</Tray>
)}
</VStack>
);
}
Prevent Dismissal
You can prevent the user from dismissing the tray with preventDismiss. This will remove built in dismiss functionality, including swipe to close with handlebar, close button, pressing ESC, and clicking outside.
You must provide an explicit action button to close the tray.
function PreventDismissTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Tray</Button>
{visible && (
<Tray
preventDismiss
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Example title"
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<Text color="fgMuted" paddingBottom={2}>
You cannot dismiss this tray by clicking outside or pressing ESC. You must click the
close button below to close it.
</Text>
</Tray>
)}
</VStack>
);
}
Accessibility
Accessibility labels
Trays require an accessibility label. If you pass in a ReactNode to title, make sure to set accessibilityLabel or accessibilityLabelledBy.
Reduce Motion
Use the reduceMotion prop to accommodate users with reduced motion settings.
function ReducedMotionTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Tray</Button>
{visible && (
<Tray
reduceMotion
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Reduced Motion"
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<Text paddingBottom={2}>This tray fades in and out using opacity.</Text>
</Tray>
)}
</VStack>
);
}
Scrollable content and keyboard navigation
If the Tray has content which is expected to overflow and doesn't have focusable elements, set the following props to ensure the scrollable content can be navigated using keyboard arrows:
focusTabIndexElements: true
disableArrowKeyNavigation: true
As well, assign a tabIndex greater than or equal to 0 to the Tray's content so that the overflow can be reached via keyboard.
function ScrollableTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Tray</Button>
{visible && (
<Tray
focusTabIndexElements
disableArrowKeyNavigation
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Scrollable Tray"
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<VStack tabIndex={0}>
<Text font="title1" paddingBottom={10}>
This tray has content which will overflow.
</Text>
<Text font="title1" paddingBottom={10}>
To enable keyboard scrolling, certain props have to be set.
</Text>
<Text font="title1" paddingBottom={10}>
Otherwise, the content won't be viewable to users who navigate using a keyboard.
</Text>
<Text font="title1" paddingBottom={10}>
It's important to account for this to ensure an accessible experience.
</Text>
<Text font="title1" paddingBottom={10}>
Here's some text that is in the overflow and needs to be scrolled to.
</Text>
<Text font="title1" paddingBottom={10}>
Here's some more text to help more easily showcase scrolling.
</Text>
</VStack>
</Tray>
)}
</VStack>
);
}
Styling
The Tray component exposes styles and classNames props for customizing various parts of the component. Available keys include: root, overlay, container, header, title, content, footer, handleBar, handleBarHandle, and closeButton.
Container
You can customize the tray's outer container to adjust the border radius for floating trays or change the max width.
function CustomContainerTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Rounded Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Custom container"
styles={{
container: {
borderRadius: 'var(--borderRadius-600)',
top: 'var(--space-2)',
bottom: 'var(--space-2)',
right: 'var(--space-2)',
},
}}
>
<Text color="fgMuted" paddingBottom={2}>
This tray has custom border radius and margin applied to the container.
</Text>
</Tray>
)}
</VStack>
);
}
Title
For full bleed images, use the title prop with a Box containing an image.
function BackgroundImageHeaderTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
const titleId = useId();
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Image Header Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title={
<Box flexGrow={1} marginX={{ base: -4, phone: -3 }}>
<img
alt="Full Bleed"
height={180}
src="/img/tray_header.png"
style={{ objectFit: 'cover', pointerEvents: 'none' }}
width="100%"
/>
</Box>
}
header={
<Text id={titleId} font="title3" paddingTop={2} paddingX={{ base: 4, phone: 3 }}>
Section header
</Text>
}
styles={{
handleBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
closeButton: {
position: 'absolute',
top: 'var(--space-4)',
right: 'var(--space-4)',
zIndex: 1,
},
header: {
paddingTop: 0,
},
content: { paddingBottom: 'var(--space-3)' },
}}
accessibilityLabelledBy={titleId}
>
<Text color="fgMuted" paddingBottom={2}>
The header displays a full bleed background image.
</Text>
</Tray>
)}
</VStack>
);
}
Content
You can customize the content area to adjust padding, background, or other properties.
function CustomContentTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Custom Content Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Custom content styling"
styles={{
content: {
backgroundColor: 'var(--color-bgSecondary)',
paddingTop: 'var(--space-3)',
paddingBottom: 'var(--space-3)',
},
}}
>
<Text color="fgMuted">
The content area has a secondary background color and custom padding.
</Text>
</Tray>
)}
</VStack>
);
}
You can customize the footer section's appearance, such as the background color.
function CustomFooterTray() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Custom Footer Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title="Custom footer styling"
styles={{
footer: {
backgroundColor: 'var(--color-bgSecondary)',
},
}}
footer={({ handleClose }) => (
<PageFooter
borderedTop
justifyContent={isPhone ? 'center' : 'flex-end'}
action={
<Button block={isPhone} onClick={handleClose}>
Close
</Button>
}
/>
)}
>
<Text color="fgMuted" paddingBottom={2}>
The footer has a secondary background color.
</Text>
</Tray>
)}
</VStack>
);
}
Handlebar
You can customize the handlebar appearance to change its color and opacity. This is useful when the default handlebar color does not have enough contrast against an image header, such as inverting it to white for dark or colorful backgrounds.
function FullBleedWithInvertedHandlebar() {
const [visible, setVisible] = useState(false);
const { isPhone } = useBreakpoints();
const handleOpen = () => setVisible(true);
const handleClose = () => setVisible(false);
const titleId = useId();
return (
<VStack gap={2}>
<Button onClick={handleOpen}>Open Full Bleed Tray</Button>
{visible && (
<Tray
pin={isPhone ? 'bottom' : 'right'}
showHandleBar={isPhone}
onCloseComplete={handleClose}
title={
<Box flexGrow={1} marginX={{ base: -4, phone: -3 }}>
<img
alt="Full Bleed"
height={180}
src="/img/tray_header.png"
style={{ objectFit: 'cover', pointerEvents: 'none' }}
width="100%"
/>
</Box>
}
header={
<Text id={titleId} font="title3" paddingTop={2} paddingX={{ base: 4, phone: 3 }}>
Section header
</Text>
}
styles={{
handleBar: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
handleBarHandle: {
backgroundColor: 'white',
opacity: 1,
},
closeButton: {
position: 'absolute',
top: 'var(--space-4)',
right: 'var(--space-4)',
zIndex: 1,
},
header: {
paddingTop: 0,
},
content: { paddingBottom: 'var(--space-3)' },
}}
accessibilityLabelledBy={titleId}
>
<Text color="fgMuted" font="body" paddingBottom={2}>
Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum
lorem id, viverra.
</Text>
</Tray>
)}
</VStack>
);
}