# Tray **📖 Live documentation:** https://cds.coinbase.com/components/overlay/Tray/ An elevated overlay container that slides in from any edge of the screen. ## Import ```tsx import { Tray } from '@coinbase/cds-web/overlays/tray/Tray' ``` ## Examples ### 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. ```jsx live function BasicTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( ( Close } /> )} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` ### 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. ```jsx live function PinnedTray() { const [pinDirection, setPinDirection] = useState(null); const { isPhone } = useBreakpoints(); const handleClose = () => setPinDirection(null); return ( {pinDirection !== null && ( ( Close } /> )} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` ### 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. ```jsx live function ResponsiveTray() { function ResponsiveTray({ styles, ...props }) { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( ); } function Example() { const [visible, setVisible] = useState(false); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( {Array.from({ length: 20 }, (_, i) => ( alert('Cell clicked!')} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1, }} /> ))} )} ); } return ; } ``` #### With Illustration in Header You can pass in a custom node to `title` to render a custom header. ```jsx live function IllustrationSectionHeaderTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Section header } accessibilityLabelledBy={titleId} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` ##### With Full Bleed Header 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. ```jsx live function FullBleedHeaderTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Full Bleed } header={ Section header } 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} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` ###### With Scrollable List Cells When using a full bleed header with scrollable content, the `header` prop keeps the section header fixed while list cells scroll beneath it. ```jsx live function FullBleedHeaderScrollableTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Full Bleed } header={ Section header } 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) => ( alert('Cell clicked!')} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1, }} /> ))} )} ); } ``` ### 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. :::tip Accessibility tip A `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. ::: ```jsx live 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 ( {visible && ( Control this tray using the ref. )} ); } ``` #### 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. ```jsx live function PreventDismissTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( ( Close } /> )} > You cannot dismiss this tray by clicking outside or pressing ESC. You must click the close button below to close it. )} ); } ``` ### 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. ```jsx live function ReducedMotionTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( ( Close } /> )} > This tray fades in and out using opacity. )} ); } ``` #### 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. ```jsx live function ScrollableTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( ( Close } /> )} > This tray has content which will overflow. To enable keyboard scrolling, certain props have to be set. Otherwise, the content won't be viewable to users who navigate using a keyboard. It's important to account for this to ensure an accessible experience. Here's some text that is in the overflow and needs to be scrolled to. Here's some more text to help more easily showcase scrolling. )} ); } ``` ### 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. ```jsx live function CustomContainerTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( This tray has custom border radius and margin applied to the container. )} ); } ``` #### Title For full bleed images, use the `title` prop with a Box containing an image. ```jsx live function BackgroundImageHeaderTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Full Bleed } header={ Section header } 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} > The header displays a full bleed background image. )} ); } ``` #### Content You can customize the content area to adjust padding, background, or other properties. ```jsx live function CustomContentTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( The content area has a secondary background color and custom padding. )} ); } ``` #### Footer You can customize the footer section's appearance, such as the background color. ```jsx live function CustomFooterTray() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( ( Close } /> )} > The footer has a secondary background color. )} ); } ``` #### 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. ```jsx live function FullBleedWithInvertedHandlebar() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Full Bleed } header={ Section header } 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} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` #### Close Button You can customize the close button to adjust the button's appearance, such as to improve visibility against header images or custom backgrounds. ```jsx live function FullBleedWithStyledCloseButton() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const titleId = useId(); return ( {visible && ( Full Bleed } header={ Section header } styles={{ closeButton: { position: 'absolute', top: 'var(--space-4)', right: 'var(--space-4)', zIndex: 1, }, header: { paddingTop: 0, }, content: { paddingBottom: 'var(--space-3)' }, }} classNames={{ closeButton: 'tray-close-button-inverted', }} accessibilityLabelledBy={titleId} > Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } ``` ### Composed Examples #### Floating You can create a floating tray by adjusting the inset based on pin direction. ```jsx live function FloatingTrayExample() { function FloatingTray({ pin = 'right', offset = '2', borderRadius = '600', children, styles, ...props }) { const theme = useTheme(); const offsetPx = theme.space[offset]; const borderRadiusVar = `var(--borderRadius-${borderRadius})`; const floatingInsets = useMemo(() => { switch (pin) { case 'right': return { top: offsetPx, bottom: offsetPx, right: offsetPx }; case 'left': return { top: offsetPx, bottom: offsetPx, left: offsetPx }; case 'top': return { top: offsetPx, left: offsetPx, right: offsetPx }; case 'bottom': return { bottom: offsetPx, left: offsetPx, right: offsetPx }; default: return { top: offsetPx, bottom: offsetPx, right: offsetPx }; } }, [pin, offsetPx]); return ( {children} ); } function Example() { const [pinDirection, setPinDirection] = useState(null); const handleClose = () => setPinDirection(null); return ( {pinDirection !== null && ( {Array.from({ length: 20 }, (_, i) => ( alert('Cell clicked!')} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1, }} /> ))} )} ); } return ; } ``` #### Multiple Screen Example You can create a tray with multiple screens that have back navigation. ```jsx live function MultiScreenTrayExample() { function MultiScreenTray({ screens, initialScreen = 0, onCloseComplete, ...props }) { const [currentScreen, setCurrentScreen] = useState(initialScreen); const screen = screens[currentScreen]; const handleBack = () => setCurrentScreen(0); const handleNavigate = (index) => setCurrentScreen(index); return ( {currentScreen > 0 && ( )} {screen.title} } accessibilityLabel={screen.title} > {screen.render({ onNavigate: handleNavigate })} ); } function Example() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); const screens = [ { title: 'Settings', render: ({ onNavigate }) => ( onNavigate(1)} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1 }} /> onNavigate(2)} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1 }} /> onNavigate(3)} innerSpacing={{ marginX: -4, paddingX: 4, paddingY: 1 }} /> ), }, { title: 'Account', render: () => ( Account settings content goes here. ), }, { title: 'Notifications', render: () => ( Notification preferences content goes here. ), }, { title: 'Privacy', render: () => ( Privacy settings content goes here. ), }, ]; return ( {visible && ( )} ); } return ; } ``` #### Header with Illustration You can create a reusable responsive tray with a pictogram and title in the header. ```jsx live function IllustrationTrayExample() { function IllustrationTray({ pictogramName, title, children, ...props }) { const titleId = useId(); return ( {title} } accessibilityLabelledBy={titleId} > {children} ); } function Example() { const [visible, setVisible] = useState(false); const { isPhone } = useBreakpoints(); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } return ; } ``` #### Responsive You can create a reusable responsive tray that adapts its pin direction and handle bar visibility based on the viewport size. ```jsx live function ResponsiveTrayExample() { function ResponsiveTray({ pin, showHandleBar, footer, footerLabel, children, ...props }) { const { isPhone } = useBreakpoints(); const resolvedFooter = footer ?? (footerLabel ? ({ handleClose }) => ( {footerLabel} } /> ) : undefined); return ( {children} ); } function Example() { const [visible, setVisible] = useState(false); const handleOpen = () => setVisible(true); const handleClose = () => setVisible(false); return ( {visible && ( Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, interdum lorem id, viverra. )} ); } return ; } ``` ## Props | Prop | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `onCloseComplete` | `() => void` | Yes | `-` | Action that will happen when tray is dismissed | | `classNames` | `{ readonly root?: string; readonly overlay?: string \| undefined; readonly container?: string \| undefined; readonly header?: string \| undefined; readonly title?: string \| undefined; readonly content?: string \| undefined; readonly handleBar?: string \| undefined; readonly handleBarHandle?: string \| undefined; readonly closeButton?: string \| undefined; } \| undefined` | No | `-` | - | | `closeAccessibilityHint` | `string` | No | `-` | Sets an accessible hint or description for the close button. On web, maps to aria-describedby and lists the id(s) of the element(s) that describe the element on which the attribute is set. On mobile, a string that helps users understand what will happen when they perform an action on the accessibility element when that result is not clear from the accessibility label. | | `closeAccessibilityLabel` | `string` | No | `-` | Sets an accessible label for the close button. On web, maps to aria-label and defines a string value that labels an interactive element. On mobile, VoiceOver will read this string when a user selects the associated element. | | `disableArrowKeyNavigation` | `boolean` | No | `-` | If true, the focus trap will not allow arrow key navigation. | | `focusTabIndexElements` | `boolean` | No | `-` | If true, the focus trap will include all elements with tabIndex values in the list of focusable elements. | | `footer` | `ReactNode \| TrayRenderChildren` | No | `-` | ReactNode to render as the Drawer footer. Can be a ReactNode or a function that receives { handleClose }. | | `header` | `ReactNode \| TrayRenderChildren` | No | `-` | ReactNode to render as the Drawer header. Can be a ReactNode or a function that receives { handleClose }. | | `hideCloseButton` | `boolean` | No | ``true` when handlebar is shown, false otherwise.` | Hide the close icon on the top right. | | `id` | `string` | No | `-` | HTML ID for the tray | | `key` | `Key \| null` | No | `-` | - | | `onBlur` | `(() => void)` | No | `-` | Callback fired when the overlay is pressed, or swipe to close | | `onClose` | `(() => void)` | No | `-` | Action that will happen when tray is dismissed | | `onOpenComplete` | `(() => void)` | No | `-` | Callback fired when the open animation completes. | | `onVisibilityChange` | `((context: hidden \| visible) => void)` | No | `-` | Optional callback that, if provided, will be triggered when the Tray is toggled open/ closed If used for analytics, context (visible \| hidden) can be bundled with the event info to track whether the multiselect was toggled into or out of view | | `pin` | `PinningDirection` | No | `'bottom'` | Pin the tray to one side of the screen | | `preventDismiss` | `boolean` | No | `-` | Prevents a user from dismissing the tray by pressing the overlay or swiping | | `reduceMotion` | `boolean` | No | `-` | When true, the tray will use opacity animation instead of transform animation. This is useful for supporting reduced motion for accessibility. | | `ref` | `((instance: TrayRefProps \| null) => void) \| RefObject \| null` | No | `-` | - | | `restoreFocusOnUnmount` | `boolean` | No | `true` | If true, the focus trap will restore focus to the previously focused element when it unmounts. | | `role` | `dialog \| alertdialog` | No | `-` | WAI-ARIA Roles | | `showHandleBar` | `boolean` | No | `-` | Show a handle bar indicator at the top of the tray. The handle bar is positioned inside the tray content area. | | `styles` | `{ readonly root?: CSSProperties; readonly overlay?: CSSProperties \| undefined; readonly container?: CSSProperties \| undefined; readonly header?: CSSProperties \| undefined; readonly title?: CSSProperties \| undefined; readonly content?: CSSProperties \| undefined; readonly handleBar?: CSSProperties \| undefined; readonly handleBarHandle?: CSSProperties \| undefined; readonly closeButton?: CSSProperties \| undefined; } \| undefined` | No | `-` | - | | `title` | `ReactNode` | No | `-` | Text or ReactNode for optional Tray title | | `verticalDrawerPercentageOfView` | `string` | No | `"85%"` | Allow user of component to define maximum percentage of screen that can be taken up by the Drawer when pinned to the bottom or top. | | `zIndex` | `number` | No | `-` | z-index for the tray overlay | ## Styles | Selector | Static class name | Description | | --- | --- | --- | | `root` | `cds-Tray` | Root container element | | `overlay` | `cds-Tray-overlay` | Overlay backdrop element | | `container` | `cds-Tray-container` | Animated sliding container element | | `header` | `cds-Tray-header` | Header section element | | `title` | `cds-Tray-title` | Title text element | | `content` | `cds-Tray-content` | Content area element | | `handleBar` | `cds-Tray-handleBar` | Handle bar container element, only rendered when showHandleBar is true and pin is bottom | | `handleBarHandle` | `cds-Tray-handleBarHandle` | Handle bar indicator element, only rendered when showHandleBar is true and pin is bottom | | `closeButton` | `cds-Tray-closeButton` | Close button element |