Panel

Panel components provide a surface for contextual information alongside the main content area.

Beta

New and ready to use. We'll provide comms and support for any major changes.

Internal use only

@atlassian/panel-system is an internal Atlassian package and is not available for external use.

Basic

A panel is composed of smaller building blocks. At a minimum, wrap your content in a PanelContainer and combine a PanelHeader (with a PanelTitle and optional PanelActionGroup), a PanelContent/PanelBody for the main content, and an optional PanelFooter for primary actions.

Basic panel example

Open in new tab, (opens new window)

This is a basic example of the panel system components.

The panel includes a header with a title and actions, and a body area for content.

import React from 'react'; import ButtonGroup from '@atlaskit/button/button-group'; import Button from '@atlaskit/button/default/button'; import { cssMap } from '@atlaskit/css'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Text } from '@atlaskit/primitives/compiled/text'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionExpand } from '@atlassian/panel-system/panel-action/expand'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelActionMore } from '@atlassian/panel-system/panel-action/more'; import { PanelActionNewTab } from '@atlassian/panel-system/panel-action/new-tab'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelFooter } from '@atlassian/panel-system/panel-footer'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; // This width is only set so the panel renders at a sensible size in the docs. // Please use the navigation-system panel slot to size panels — do not hardcode this. const styles = cssMap({ exampleWrapper: { width: '400px', height: '480px', }, }); export default function BasicPanel(): React.JSX.Element { return ( <Box xcss={styles.exampleWrapper}> <PanelContainer testId="basic-panel"> <PanelHeader> <PanelTitle>Basic panel example</PanelTitle> <PanelActionGroup> <PanelActionExpand onClick={() => {}} /> <PanelActionNewTab href="https://atlassian.design/components" /> <PanelActionMore onClick={() => {}} /> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelBody> <Text as="p">This is a basic example of the panel system components.</Text> <Text as="p"> The panel includes a header with a title and actions, and a body area for content. </Text> </PanelBody> </PanelContent> <PanelFooter> <ButtonGroup label="Panel actions"> <Button appearance="subtle" onClick={() => {}}> Cancel </Button> <Button appearance="primary" onClick={() => {}}> Save </Button> </ButtonGroup> </PanelFooter> </PanelContainer> </Box> ); }

PanelHeader provides a consistent header area for a panel. Compose it with PanelTitle (with an optional icon or app logo and back button) and a PanelActionGroup of panel actions such as expand, open in new tab, more, and close.

Header title

Header title

App name

App name

App name

Open in new tab, (opens new window)

Header title

import React from 'react'; import { cssMap, jsx } from '@atlaskit/css'; import InformationCircleIcon from '@atlaskit/icon/core/information-circle'; import { JiraIcon } from '@atlaskit/logo/jira-icon'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { token } from '@atlaskit/tokens'; import { PanelActionBack } from '@atlassian/panel-system/panel-action/back'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionDown } from '@atlassian/panel-system/panel-action/down'; import { PanelActionExpand } from '@atlassian/panel-system/panel-action/expand'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelActionMore } from '@atlassian/panel-system/panel-action/more'; import { PanelActionNewTab } from '@atlassian/panel-system/panel-action/new-tab'; import { PanelActionUp } from '@atlassian/panel-system/panel-action/up'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; // Each header variation is wrapped in a bordered box purely for presentation in the docs. // In a real panel, render `PanelHeader` directly inside a `PanelContainer`. const styles = cssMap({ gallery: { width: '400px', }, headerWrapper: { borderColor: token('color.border'), borderStyle: 'solid', borderWidth: token('border.width'), borderRadius: token('radius.small'), overflow: 'hidden', }, }); function HeaderVariant({ children }: { children: React.ReactNode }) { return <Box xcss={styles.headerWrapper}>{children}</Box>; } export default function Headers(): React.JSX.Element { return ( <Stack space="space.300" xcss={styles.gallery}> {/* Basic */} <HeaderVariant> <PanelHeader> <PanelTitle>Header title</PanelTitle> <PanelActionGroup> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Basic + title icon */} <HeaderVariant> <PanelHeader> <PanelTitle icon={<InformationCircleIcon label="" />}>Header title</PanelTitle> <PanelActionGroup> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Branded */} <HeaderVariant> <PanelHeader> <PanelTitle icon={<JiraIcon size="xsmall" appearance="brand" />}>App name</PanelTitle> <PanelActionGroup> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Branded + back button */} <HeaderVariant> <PanelHeader> <PanelActionBack onClick={() => {}} /> <PanelTitle icon={<JiraIcon size="xsmall" appearance="brand" />}>App name</PanelTitle> <PanelActionGroup> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Branded + panel action */} <HeaderVariant> <PanelHeader> <PanelTitle icon={<JiraIcon size="xsmall" appearance="brand" />}>App name</PanelTitle> <PanelActionGroup> <PanelActionNewTab href="#" /> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Branded + app panel + 2 panel actions */} <HeaderVariant> <PanelHeader> <PanelTitle icon={<JiraIcon size="xsmall" appearance="brand" />}>App name</PanelTitle> <PanelActionGroup> <PanelActionMore onClick={() => {}} /> <PanelActionExpand onClick={() => {}} /> <PanelActionNewTab href="#" /> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> {/* Navigation actions come before all other header actions. */} <HeaderVariant> <PanelHeader> <PanelTitle>Header title</PanelTitle> <PanelActionGroup> <PanelActionUp onClick={() => {}} /> <PanelActionDown onClick={() => {}} /> <PanelActionMore onClick={() => {}} /> <PanelActionClose onClick={() => {}} /> </PanelActionGroup> </PanelHeader> </HeaderVariant> </Stack> ); }

This Jira-style example keeps the list selection and panel content in sync. Open a work item, then use the up/down actions to move to the previous or next item in list order. Navigation wraps at both ends. Closing the panel returns focus to the current work item in the list.

The host owns the selected item; both actions come first in PanelActionGroup. The view switcher keeps the current item selected when moving between Side and Center views. Scroll the details to see the compact sticky subheader with the current title and status.

import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react'; import Avatar from '@atlaskit/avatar/avatar'; import Button from '@atlaskit/button/default/button'; import { cssMap, jsx } from '@atlaskit/css'; import type MediaBelowSm from '@atlaskit/css/at-rules/media-below-sm'; import Heading from '@atlaskit/heading/heading'; import ModalIcon from '@atlaskit/icon-lab/core/modal'; import PanelRightIcon from '@atlaskit/icon/core/panel-right'; import TaskIcon from '@atlaskit/icon/core/task'; import { JiraIcon } from '@atlaskit/logo/jira-icon'; import Lozenge from '@atlaskit/lozenge/lozenge'; import Modal from '@atlaskit/modal-dialog/modal-dialog'; import ModalHeader from '@atlaskit/modal-dialog/modal-header'; import ModalTitle from '@atlaskit/modal-dialog/modal-title'; import ModalTransition from '@atlaskit/modal-dialog/modal-transition'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Inline } from '@atlaskit/primitives/compiled/inline'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionDown } from '@atlassian/panel-system/panel-action/down'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelActionUp } from '@atlassian/panel-system/panel-action/up'; import { PanelActionViewSwitcher, type PanelViewOption, } from '@atlassian/panel-system/panel-action/view-switcher'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelSubheader } from '@atlassian/panel-system/panel-subheader'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; const issues = [ { key: 'CAT-991', summary: 'Prepare imagery for the content update', status: 'Done', assignee: 'Alex Morgan', priority: 'Minor', description: 'Refresh the Foundations imagery so the documentation reflects the latest design language.', done: 'Updated images are reviewed and ready to publish with the documentation release.', }, { key: 'CAT-997', summary: 'Update the Figma component playground', status: 'In progress', assignee: 'Sam Chen', priority: 'Major', description: 'Bring the component playground up to date with the latest panel actions and layout patterns.', done: 'Designers can explore the new actions in the playground and find the matching code examples.', }, { key: 'CAT-1042', summary: 'Review the visual design of work item details', status: 'To do', assignee: 'Jordan Lee', priority: 'Major', description: 'Review the hierarchy, spacing, and status treatment in the work item details panel.', done: 'The review covers desktop and narrow layouts, and feedback is captured for implementation.', }, { key: 'CAT-1074', summary: 'Improve the empty state for filtered lists', status: 'In progress', assignee: 'Taylor Reed', priority: 'Minor', description: 'Help people understand when no work matches their filters and how to recover.', done: 'The empty state explains the result and offers a clear way to adjust the filters.', }, { key: 'CAT-992', summary: 'Document responsive typography decisions', status: 'Done', assignee: 'Casey Patel', priority: 'Minor', description: 'Record the typography decisions used across list views and contextual panels.', done: 'Guidance includes readable examples at desktop, narrow widths, and increased zoom.', }, { key: 'CAT-1075', summary: 'Plan the next design system milestone', status: 'To do', assignee: 'Alex Morgan', priority: 'Major', description: 'Bring together the next set of component improvements and agree on the delivery order.', done: 'Each work item has an owner, a clear outcome, and a place in the milestone.', }, ] as const; const views: readonly PanelViewOption[] = [ { value: 'sidebar', label: 'Side', icon: PanelRightIcon }, { value: 'center', label: 'Center', icon: ModalIcon }, ]; const activity = [ [ 'Scope confirmed', 'The team agreed on the expected outcome and the work needed for this item. The description reflects the latest scope.', ], [ 'Design review', 'Reviewed the proposed experience against the design system. Follow-up notes cover spacing, content hierarchy, and responsive layouts.', ], [ 'Accessibility review', 'Checked keyboard navigation, focus order, and accessible names. The implementation should preserve context when moving between work items.', ], [ 'Implementation started', 'The first version is ready for a closer look. The team is checking the main flow and capturing any edge cases.', ], [ 'Feedback incorporated', 'Updated the work based on review comments. The revised approach keeps the interaction consistent with nearby experiences.', ], [ 'Validation planned', 'Validation covers desktop and narrow layouts, keyboard use, and moving between related work items. Results will be recorded here.', ], [ 'Documentation reviewed', 'The example and supporting guidance describe the intended behavior. Reviewers can use them to try the workflow themselves.', ], [ 'Next steps agreed', 'The owner will follow up on outstanding feedback and share the next update with the team. The definition of done remains the completion checklist.', ], ] as const; const styles = cssMap({ layout: { display: 'flex', height: '100vh', backgroundColor: token('elevation.surface'), ['@media not all and (min-width: 48rem)' satisfies MediaBelowSm]: { flexDirection: 'column', height: 'auto', minHeight: '100vh', }, }, main: { overflow: 'auto', flexGrow: 1, minWidth: 0, paddingBlockStart: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), paddingInlineEnd: token('space.300'), }, listHeading: { paddingBlockEnd: token('space.150'), borderBlockEndColor: token('color.border.selected'), borderBlockEndStyle: 'solid', borderBlockEndWidth: token('border.width.selected'), }, tableRegion: { overflowX: 'auto', borderColor: token('color.border'), borderStyle: 'solid', borderWidth: token('border.width'), borderRadius: token('radius.small'), }, table: { width: '100%', minWidth: '640px', borderCollapse: 'collapse', }, cell: { paddingBlockStart: token('space.150'), paddingBlockEnd: token('space.150'), paddingInlineStart: token('space.150'), paddingInlineEnd: token('space.150'), textAlign: 'start', borderBlockEndColor: token('color.border'), borderBlockEndStyle: 'solid', borderBlockEndWidth: token('border.width'), }, columnHeading: { backgroundColor: token('elevation.surface.sunken'), color: token('color.text.subtle'), font: token('font.body.small'), fontWeight: token('font.weight.semibold'), }, summary: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 28px', alignItems: 'center', columnGap: token('space.150'), }, selectionSlot: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '28px', height: '28px', }, selectionIndicator: { display: 'inline-flex', paddingBlockStart: token('space.050'), paddingBlockEnd: token('space.050'), paddingInlineStart: token('space.050'), paddingInlineEnd: token('space.050'), backgroundColor: token('color.background.selected'), borderColor: token('color.border.selected'), borderStyle: 'solid', borderWidth: token('border.width'), borderRadius: token('radius.small'), }, panel: { height: '100%', width: '380px', maxWidth: '100%', flexShrink: 0, borderInlineStartColor: token('color.border'), borderInlineStartStyle: 'solid', borderInlineStartWidth: token('border.width'), ['@media not all and (min-width: 48rem)' satisfies MediaBelowSm]: { width: '100%', height: '80vh', borderInlineStartWidth: 0, borderBlockStartColor: token('color.border'), borderBlockStartStyle: 'solid', borderBlockStartWidth: token('border.width'), }, }, panelRegion: { height: '100%', minHeight: 0, }, modalHeader: { width: '100%', minWidth: 0, }, stickyRow: { display: 'flex', alignItems: 'center', gap: token('space.100'), width: '100%', minWidth: 0, }, stickyTitle: { flexGrow: 1, minWidth: 0, }, stickyStatus: { flexShrink: 0, }, activityItem: { paddingBlockEnd: token('space.200'), borderBlockEndColor: token('color.border'), borderBlockEndStyle: 'solid', borderBlockEndWidth: token('border.width'), }, panelBody: { paddingBlockEnd: token('space.300'), }, details: { display: 'grid', gridTemplateColumns: '100px minmax(0, 1fr)', alignItems: 'center', columnGap: token('space.200'), rowGap: token('space.200'), marginBlockStart: 0, marginBlockEnd: 0, }, value: { marginInlineStart: 0, }, }); function IssueStatus({ status }: { status: (typeof issues)[number]['status'] }) { return ( <Lozenge appearance={ status === 'Done' ? 'success' : status === 'In progress' ? 'inprogress' : 'default' } > {status} </Lozenge> ); } /** * The host list owns selection and ordering. Navigation replaces the current issue's * content without remounting the panel or moving focus away from its action buttons. */ export default function JiraListNavigationExample(): JSX.Element { const [selectedIndex, setSelectedIndex] = useState<number | null>(1); const [selectedView, setSelectedView] = useState('sidebar'); const returnFocus = useRef<HTMLElement | null>(null); const issue = selectedIndex === null ? null : issues[selectedIndex]; const issueButtons = useRef(new Map<string, HTMLButtonElement>()); const sidePanelRef = useRef<HTMLDivElement | null>(null); const centerPanelRef = useRef<HTMLDivElement>(null); const focusPanelOnSelection = useRef(false); const setSidePanelRef = useCallback((element: HTMLDivElement | null) => { sidePanelRef.current = element; if (element) { returnFocus.current = element; } }, []); useEffect(() => { if (focusPanelOnSelection.current) { sidePanelRef.current?.focus(); focusPanelOnSelection.current = false; } }, [selectedIndex]); const selectIssue = (index: number) => { setSelectedView('sidebar'); if (selectedIndex === index) { sidePanelRef.current?.focus(); } else { focusPanelOnSelection.current = true; setSelectedIndex(index); } }; const closePanel = () => { returnFocus.current = issue ? (issueButtons.current.get(issue.key) ?? null) : null; if (selectedView === 'sidebar') { returnFocus.current?.focus(); } setSelectedIndex(null); }; // This example wraps at either end, so both navigation actions remain available. const navigate = (direction: -1 | 1) => { setSelectedIndex((current) => current === null ? current : (current + direction + issues.length) % issues.length, ); }; const navigationActions = ( <PanelActionGroup> <PanelActionUp label="Previous work item" onClick={() => navigate(-1)} /> <PanelActionDown label="Next work item" onClick={() => navigate(1)} /> <PanelActionViewSwitcher views={views} selectedView={selectedView} onViewChange={setSelectedView} /> {selectedView === 'sidebar' && ( <PanelActionClose label="Close work item details" onClick={closePanel} /> )} </PanelActionGroup> ); const panel = issue && selectedIndex !== null && ( <Box id="jira-list-issue-panel" ref={selectedView === 'sidebar' ? setSidePanelRef : centerPanelRef} role="region" aria-label={`Work item details: ${issue.summary}`} tabIndex={-1} xcss={styles.panelRegion} onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => { if ( event.key === 'Escape' && selectedView === 'sidebar' && !event.defaultPrevented && !event.currentTarget.querySelector('[role="menu"]') ) { event.stopPropagation(); closePanel(); } }} > <PanelContainer> {selectedView === 'center' ? ( <ModalHeader hasCloseButton closeButtonLabel="Close work item details"> <Flex alignItems="center" justifyContent="space-between" gap="space.200" xcss={styles.modalHeader} > <ModalTitle>Jira</ModalTitle> {navigationActions} </Flex> </ModalHeader> ) : ( <PanelHeader> <PanelTitle icon={<JiraIcon size="xsmall" />}>Jira</PanelTitle> {navigationActions} </PanelHeader> )} <PanelContent testId="jira-list-panel-content"> <PanelSubheader title={issue.summary} titleIcon={<TaskIcon label="" />} breadcrumbs={ <div role="status" aria-live="polite" aria-atomic="true"> <Text size="small" color="color.text.subtle"> {selectedIndex + 1} of {issues.length} · {issue.key} </Text> </div> } stickyContent={ <div css={styles.stickyRow}> <Box xcss={styles.stickyTitle}> <Text maxLines={1} weight="semibold"> {issue.key} · {issue.summary} </Text> </Box> <Box xcss={styles.stickyStatus}> <IssueStatus status={issue.status} /> </Box> </div> } testId="jira-list-subheader" /> <PanelBody> <Box xcss={styles.panelBody}> <Stack space="space.300"> <Box> <IssueStatus status={issue.status} /> </Box> <Stack space="space.100"> <Heading size="small" as="h3"> Description </Heading> <Text>{issue.description}</Text> </Stack> <Stack space="space.100"> <Heading size="small" as="h3"> Definition of done </Heading> <Text>{issue.done}</Text> </Stack> <Stack space="space.200"> <Heading size="small" as="h3"> Details </Heading> <dl css={styles.details}> <dt>Assignee</dt> <dd css={styles.value}> <Inline space="space.100" alignBlock="center"> <Avatar size="small" name={issue.assignee} /> <Text>{issue.assignee}</Text> </Inline> </dd> <dt>Priority</dt> <dd css={styles.value}>{issue.priority}</dd> <dt>Project</dt> <dd css={styles.value}>Design system</dd> </dl> </Stack> <Stack space="space.200"> <Heading size="small" as="h3"> Activity </Heading> {activity.map(([title, description]) => ( <Box key={title} xcss={styles.activityItem}> <Stack space="space.100"> <Text weight="semibold">{title}</Text> <Text size="small" color="color.text.subtle"> {issue.assignee} · {issue.key} </Text> <Text>{description}</Text> </Stack> </Box> ))} </Stack> </Stack> </Box> </PanelBody> </PanelContent> </PanelContainer> </Box> ); return ( <Box xcss={styles.layout}> <Box as="main" xcss={styles.main}> <Stack space="space.300"> <Stack space="space.100"> <Text size="small" color="color.text.subtle"> Spaces / Design system </Text> <Heading size="large" as="h1"> ADS Components &amp; Architecture </Heading> </Stack> <Box xcss={styles.listHeading}> <Text weight="semibold">List</Text> </Box> <Text> Open a work item, then use the panel's previous and next actions to browse the list. Navigation wraps at the first and last item. Switch between Side and Center views, or scroll the details to see the sticky subheader. </Text> <div role="region" aria-label="Work items" css={styles.tableRegion}> <table css={styles.table} aria-label="Design system work items"> <thead> <tr> {['Work', 'Summary', 'Status', 'Priority', 'Assignee'].map((column) => ( <th key={column} scope="col" css={[styles.cell, styles.columnHeading]}> {column} </th> ))} </tr> </thead> <tbody> {issues.map((item, index) => ( <tr key={item.key}> <td css={styles.cell}> <Button ref={(element) => { if (element) { issueButtons.current.set(item.key, element); } else { issueButtons.current.delete(item.key); } }} appearance="subtle" spacing="compact" iconBefore={TaskIcon} aria-label={`Open ${item.key}: ${item.summary}`} aria-expanded={selectedIndex === index} aria-controls={ selectedIndex === index ? 'jira-list-issue-panel' : undefined } onClick={() => selectIssue(index)} > {item.key} </Button> </td> <td css={styles.cell}> <div css={styles.summary}> <span>{item.summary}</span> <span css={styles.selectionSlot}> {selectedIndex === index && ( <span css={styles.selectionIndicator}> <PanelRightIcon label="Currently open in details panel" color={token('color.icon.selected')} /> </span> )} </span> </div> </td> <td css={styles.cell}> <IssueStatus status={item.status} /> </td> <td css={styles.cell}>{item.priority}</td> <td css={styles.cell}>{item.assignee}</td> </tr> ))} </tbody> </table> </div> <Text size="small" color="color.text.subtle"> {issues.length} work items </Text> </Stack> </Box> {issue && selectedView === 'sidebar' && <Box xcss={styles.panel}>{panel}</Box>} <ModalTransition> {issue && selectedView === 'center' && ( // The shared panel renders ModalHeader with its standard close button. <Modal width="large" height="80vh" onClose={closePanel} autoFocus={centerPanelRef} shouldReturnFocus={returnFocus} > {panel} </Modal> )} </ModalTransition> </Box> ); }

PanelFooter anchors actions to the bottom of a panel. Actions can be right aligned or spread out, and an optional PanelDisclaimer can be added below the actions in one or two zones.

Powered by Rovo AI. Verify results.
Powered by Rovo AI. Verify results.
import React, { useState } from 'react'; import ButtonGroup from '@atlaskit/button/button-group'; import Button from '@atlaskit/button/default/button'; import IconButton from '@atlaskit/button/icon/button'; import { Checkbox } from '@atlaskit/checkbox/checkbox'; import { cssMap, jsx } from '@atlaskit/css'; import ThumbsDownIcon from '@atlaskit/icon/core/thumbs-down'; import ThumbsUpIcon from '@atlaskit/icon/core/thumbs-up'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Inline } from '@atlaskit/primitives/compiled/inline'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import TextArea from '@atlaskit/textarea/text-area'; import { token } from '@atlaskit/tokens'; import { PanelDisclaimer } from '@atlassian/panel-system/panel-disclaimer'; import { PanelFooter } from '@atlassian/panel-system/panel-footer'; // Each footer variation is wrapped in a bordered box purely for presentation in the docs. // In a real panel, render `PanelFooter` directly inside a `PanelContainer`. const styles = cssMap({ gallery: { width: '400px', }, footerWrapper: { borderColor: token('color.border'), borderStyle: 'solid', borderWidth: token('border.width'), borderRadius: token('radius.small'), overflow: 'hidden', }, }); function FooterVariant({ children }: { children: React.ReactNode }) { return <Box xcss={styles.footerWrapper}>{children}</Box>; } function CommentComposer() { const [comment, setComment] = useState(''); return ( <Stack space="space.150" grow="fill"> <TextArea value={comment} onChange={(event) => setComment(event.currentTarget.value)} minimumRows={3} aria-label="Add a comment" /> <Flex justifyContent="space-between" alignItems="center"> <Checkbox label="Watch this content" /> <Button appearance="primary" onClick={() => setComment('')}> Add comment </Button> </Flex> </Stack> ); } export default function Footers(): React.JSX.Element { return ( <Stack space="space.300" xcss={styles.gallery}> {/* Right aligned buttons */} <FooterVariant> <PanelFooter> <ButtonGroup label="Panel actions"> <Button appearance="subtle" onClick={() => {}}> Secondary action </Button> <Button appearance="primary" onClick={() => {}}> Primary action </Button> </ButtonGroup> </PanelFooter> </FooterVariant> {/* Spread out buttons */} <FooterVariant> <PanelFooter> <Button appearance="primary" onClick={() => {}}> Primary action </Button> <Button appearance="default" onClick={() => {}}> Secondary action </Button> </PanelFooter> </FooterVariant> {/* Footer + disclaimer bar (1 zone) */} <FooterVariant> <PanelFooter disclaimer={ <PanelDisclaimer> <Flex justifyContent="center"> <Text size="small" color="color.text.subtle"> Powered by Rovo AI. Verify results. </Text> </Flex> </PanelDisclaimer> } > <Button appearance="primary" onClick={() => {}}> Primary action </Button> <Checkbox label="Label" /> </PanelFooter> </FooterVariant> {/* Footer + disclaimer bar (2 zones) */} <FooterVariant> <PanelFooter disclaimer={ <PanelDisclaimer> <Flex justifyContent="space-between" alignItems="center"> <Inline space="space.025"> <IconButton icon={ThumbsUpIcon} label="Helpful" appearance="subtle" spacing="compact" onClick={() => {}} /> <IconButton icon={ThumbsDownIcon} label="Not helpful" appearance="subtle" spacing="compact" onClick={() => {}} /> </Inline> <Text size="small" color="color.text.subtle"> Powered by Rovo AI. Verify results. </Text> </Flex> </PanelDisclaimer> } > <Button appearance="primary" onClick={() => {}}> Primary action </Button> <Checkbox label="Label" /> </PanelFooter> </FooterVariant> {/* Footer open slot */} <FooterVariant> <PanelFooter> <CommentComposer /> </PanelFooter> </FooterVariant> </Stack> ); }

Layout

Panel system is designed to be rendered inside the LayoutWithPanel component. This should live inside navigation system's Main slot. Panels opened via Trigger, or programmatically will automatically render inside LayoutWithPanel docked to the side of the page with consistent layout, spacing, and resize behaviour. This example shows a complete side panel — including a PanelSubheader, a cover image, and a PanelDisclaimer — rendered through LayoutWithPanel.

import { useEffect } from 'react'; import Button from '@atlaskit/button/default/button'; import IconButton from '@atlaskit/button/icon/button'; import { Checkbox } from '@atlaskit/checkbox/checkbox'; import { cssMap, jsx } from '@atlaskit/css'; import Heading from '@atlaskit/heading/heading'; import PageIcon from '@atlaskit/icon/core/page'; import ThumbsDownIcon from '@atlaskit/icon/core/thumbs-down'; import ThumbsUpIcon from '@atlaskit/icon/core/thumbs-up'; import { ConfluenceIcon } from '@atlaskit/logo/confluence-icon'; import { Main } from '@atlaskit/navigation-system/layout/main'; import { Root } from '@atlaskit/navigation-system/layout/root'; import { Anchor } from '@atlaskit/primitives/compiled/anchor'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { LayoutWithPanel } from '@atlassian/panel-system/layout-with-panel'; import { PanelActionBack } from '@atlassian/panel-system/panel-action/back'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelActionMore } from '@atlassian/panel-system/panel-action/more'; import { PanelActionNewTab } from '@atlassian/panel-system/panel-action/new-tab'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelDisclaimer } from '@atlassian/panel-system/panel-disclaimer'; import { PanelFooter } from '@atlassian/panel-system/panel-footer'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { usePanelActions } from '@atlassian/panel-system/panel-manager'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelSubheader } from '@atlassian/panel-system/panel-subheader'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; import { PanelCoverImage } from '../images/panel-cover'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ mainContent: { flex: 1, paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), }, }); export function SidePanelExample(): JSX.Element { return ( <PanelProvider> <RelayEnvironmentWrapper> <Root> <Main> <AppContent /> </Main> </Root> </RelayEnvironmentWrapper> </PanelProvider> ); } const AppContent = () => { const manager = usePanelActions(); useEffect(() => { // Prefer <Trigger/> with entry points for loading and focus management. manager.openPanel({ instanceId: 'confluence-panel', panelContent: <Panel />, }); }, [manager]); return ( <LayoutWithPanel> <Box xcss={styles.mainContent}> <Stack space="space.200"> <Heading size="medium">Main content area</Heading> <Text> Panel system is designed to be rendered inside the navigation system's `Panel` slot. Compose your panel with `PanelContainer` and place it inside `Panel` so it sits alongside the page's `Main` content with consistent layout and resize behaviour. </Text> </Stack> </Box> </LayoutWithPanel> ); }; const Panel = () => { return ( <PanelContainer testId="preview-panel"> <PanelHeader> <PanelActionBack onClick={() => {}} testId="back-action" /> <PanelTitle icon={<ConfluenceIcon size="xsmall" />}>Confluence</PanelTitle> <PanelActionGroup> <PanelActionNewTab href="/projects/blueshift" onClick={() => {}} testId="new-tab-action" /> <PanelActionMore onClick={() => {}} testId="more-action" /> <PanelActionClose onClick={() => {}} testId="close-action" /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelSubheader title="Subheading title" titleIcon={<PageIcon label="" />} coverImage={<PanelCoverImage />} /> <PanelBody> <Stack space="space.200"> <Heading size="small" as="h3"> Project overview </Heading> <Text> Panel system is a set of platform components to standardize the look and feel of panels in Atlassian products. </Text> <Heading size="xsmall" as="h4"> Key features </Heading> <Box as="ul"> <Box as="li">Unified design system components</Box> <Box as="li">Consistent navigation patterns</Box> <Box as="li">Improved accessibility standards</Box> </Box> <Heading size="xsmall" as="h4"> Long content (this should wrap) </Heading> <Anchor href="https://example.atlassian.net/browse/longnamelongnamelongnamelongnamelongnamelongnamelongnamelongnamelongname"> https://example.atlassian.net/browse/longnamelongnamelongnamelongnamelongnamelongnamelongnamelongnamelongname </Anchor> </Stack> </PanelBody> </PanelContent> <PanelFooter disclaimer={ <PanelDisclaimer> <Flex justifyContent="space-between" alignItems="center"> <Flex gap="space.025"> <IconButton icon={ThumbsUpIcon} label="Helpful" appearance="subtle" spacing="compact" onClick={() => {}} /> <IconButton icon={ThumbsDownIcon} label="Not helpful" appearance="subtle" spacing="compact" onClick={() => {}} /> </Flex> <Text size="small" color="color.text.subtle"> Powered by Rovo AI. Verify results. </Text> </Flex> </PanelDisclaimer> } > <Button appearance="primary" onClick={() => {}}> Save </Button> <Checkbox label="Create another" isChecked={false} onChange={() => {}} /> </PanelFooter> </PanelContainer> ); }; export default SidePanelExample;

Subheader

Use PanelSubheader to display a secondary heading beneath the header. It supports a coverImage for displaying a banner image at the top of the panel, and inline editing, allowing people to update fields such as a title directly from within the panel. Use stickyContent to provide any React content that appears after the regular subheader scrolls out of view.

Page details

Project overview

The subheader title above is editable in place. Select it to enter edit mode, change the text, then confirm or cancel.
import React, { useState } from 'react'; import Breadcrumbs from '@atlaskit/breadcrumbs/breadcrumbs'; import { BreadcrumbsCurrentItem } from '@atlaskit/breadcrumbs/breadcrumbs-current-item'; import { BreadcrumbsItem } from '@atlaskit/breadcrumbs/breadcrumbs-item'; import { cssMap } from '@atlaskit/css'; import Heading from '@atlaskit/heading/heading'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelSubheader } from '@atlassian/panel-system/panel-subheader'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; import { SubheaderInlineEdit } from '@atlassian/panel-system/subheader-inline-edit'; import { PanelCoverImage } from '../images/panel-cover'; const subheaderBreadcrumbs = ( <Breadcrumbs size="small"> <BreadcrumbsItem href="#" text="Workspace" /> <BreadcrumbsItem href="#" text="Projects" /> <BreadcrumbsItem href="#" text="Team space" /> <BreadcrumbsCurrentItem href="#" text="CAT-2591" /> </Breadcrumbs> ); // This width is only set so the panel renders at a sensible size in the docs. // Please use the navigation-system panel slot to size panels — do not hardcode this. const styles = cssMap({ exampleWrapper: { width: '400px', height: '480px', }, }); export function SubheaderInlineEditExample(): React.JSX.Element { const [title, setTitle] = useState('Subheading title'); return ( <Box xcss={styles.exampleWrapper}> <PanelContainer testId="preview-panel"> <PanelHeader> <PanelTitle>Page details</PanelTitle> <PanelActionGroup> <PanelActionClose onClick={() => {}} testId="close-action" /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelSubheader title={title} breadcrumbs={subheaderBreadcrumbs} coverImage={<PanelCoverImage />} inlineEdit={ <SubheaderInlineEdit defaultValue={title} placeholder="Give this panel a title" editButtonLabel="Edit subheading title" onConfirm={(value) => setTitle(value)} testId="subheader-inline-edit" /> } /> <PanelBody> <Stack space="space.200"> <Heading size="small" as="h3"> Project overview </Heading> <Text> The subheader title above is editable in place. Select it to enter edit mode, change the text, then confirm or cancel. </Text> </Stack> </PanelBody> </PanelContent> </PanelContainer> </Box> ); } export default SubheaderInlineEditExample;

Resizing

When a panel is rendered within the navigation system layout, it can be resized using the PanelSplitter. This lets people give more or less screen space to the panel as needed.

import React from 'react'; import Button from '@atlaskit/button/default/button'; import { Checkbox } from '@atlaskit/checkbox/checkbox'; import { cssMap } from '@atlaskit/css'; import { Main } from '@atlaskit/navigation-system/layout/main'; import { Panel } from '@atlaskit/navigation-system/layout/panel'; import { PanelSplitter } from '@atlaskit/navigation-system/layout/panel-splitter'; import { Root } from '@atlaskit/navigation-system/layout/root'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelFooter } from '@atlassian/panel-system/panel-footer'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelSubheader } from '@atlassian/panel-system/panel-subheader'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; import { PanelCoverImage } from '../images/panel-cover'; const styles = cssMap({ mainContent: { flex: 1, paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), }, }); export function ResizingExample(): JSX.Element { return ( <Root> <Main> <Box xcss={styles.mainContent}> <Text> Click and drag the left edge of the panel to resize it. The PanelSplitter from @atlaskit/navigation-system handles all resize logic. </Text> </Box> </Main> <Panel defaultWidth={400} label="Resizable panel"> <PanelContainer> <PanelHeader> <PanelTitle>Resizable Panel</PanelTitle> </PanelHeader> <PanelContent> <PanelSubheader title="Subheading title" coverImage={<PanelCoverImage />} /> <PanelBody> <Text>This is a simple example showing the resize functionality.</Text> </PanelBody> </PanelContent> <PanelFooter> <Checkbox label="Create another" isChecked={false} onChange={() => {}} /> <Button appearance="primary" onClick={() => {}}> Create </Button> </PanelFooter> </PanelContainer> <PanelSplitter label="Resize panel" /> </Panel> </Root> ); } export default ResizingExample;

Default loading state

When using PanelRouter, a loading skeleton is provided by default which offers a basic outline of a panel with a header, subheader, and body content.

import { cssMap, jsx } from '@atlaskit/css'; import { Box } from '@atlaskit/primitives/compiled/box'; import { PanelSkeleton } from '@atlassian/panel-system/panel-skeleton'; // This width is only set so the panel renders at a sensible size in the docs. // Please use the navigation-system panel slot to size panels — do not hardcode this. // In a real panel, the default skeleton is provided automatically by `PanelRouter`. const styles = cssMap({ exampleWrapper: { width: '400px', height: '480px', }, }); export function LoadingDefault(): JSX.Element { return ( <Box xcss={styles.exampleWrapper}> <PanelSkeleton /> </Box> ); } export default LoadingDefault;

Custom loading state

You can also create your own loading skeleton by composing some ready-made components: PanelHeaderSkeleton, PanelSubheaderSkeleton, PanelBodySkeleton, and PanelFooterSkeleton. These can also be used with your own mix of skeleton components. Each of these components implements the same spacing as their respective layout components, so PanelHeaderSkeleton mimics PanelHeader and so on. Provide your custom loading state to PanelRouter's fallback prop.

import { cssMap, jsx } from '@atlaskit/css'; import { Box } from '@atlaskit/primitives/compiled/box'; import { PanelBodySkeleton } from '@atlassian/panel-system/panel-body-skeleton'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { PanelFooterSkeleton } from '@atlassian/panel-system/panel-footer-skeleton'; import { PanelHeaderSkeleton } from '@atlassian/panel-system/panel-header-skeleton'; import { PanelSubheaderSkeleton } from '@atlassian/panel-system/panel-subheader-skeleton'; // This width is only set so the panel renders at a sensible size in the docs. // Please use the navigation-system panel slot to size panels — do not hardcode this. // In a real panel, pass your custom skeleton to `PanelRouter`'s `fallback` prop. const styles = cssMap({ exampleWrapper: { width: '400px', height: '480px', }, }); export function LoadingComposable(): JSX.Element { return ( <Box xcss={styles.exampleWrapper}> <PanelContainer testId="panel-container-skeleton"> <PanelHeaderSkeleton shouldShowIcon actionsCount={3} /> <PanelContent> <PanelSubheaderSkeleton shouldShowIcon /> <PanelBodySkeleton /> </PanelContent> <PanelFooterSkeleton actionsCount={2} /> </PanelContainer> </Box> ); } export default LoadingComposable;

Panel manager

The PanelProvider, PanelRouter, and usePanelManager APIs let you manage panel state declaratively. Use the manager to open, replace, and stack panels, and to navigate between different panel EntryPoints.

import React, { useCallback, useEffect, useRef } from 'react'; import { type EntryPoint, loadEntryPoint } from 'react-relay'; import Button from '@atlaskit/button/default/button'; import { cssMap } from '@atlaskit/css'; import { useId } from '@atlaskit/ds-lib/use-id'; import Heading from '@atlaskit/heading/heading'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { usePanelManager } from '@atlassian/panel-system'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelRouter } from '@atlassian/panel-system/panel-router'; import { PanelsRenderer } from '@atlassian/panel-system/panels-renderer'; import { useRelayEnvironmentProvider } from '@atlassian/relay-environment-provider'; import { panelEntryPoint } from '../utils/dummy-entrypoint'; import DummyEntryPointComponent from '../utils/dummy-entrypoint-component'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ layout: { height: '100vh', width: '100%', }, mainContent: { flex: 1, paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), }, panelWrapper: { borderInlineStart: `${token('border.width')} solid ${token('color.border')}`, width: '480px', }, buttonGroup: { display: 'flex', gap: token('space.100'), flexWrap: 'wrap', }, stateDisplay: { paddingBlockStart: token('space.200'), paddingInlineEnd: token('space.200'), paddingBlockEnd: token('space.200'), paddingInlineStart: token('space.200'), backgroundColor: token('color.background.neutral'), }, }); // Helper to get entry point name for display function getEntryPointName(entryPoint: EntryPoint<any, any>): string { const root = entryPoint.root as any; return root?.getModuleName?.() || root?.getModuleId?.() || 'unknown-entrypoint'; } function PanelManagerExample() { const { state, manager } = usePanelManager(); const baseId = useId(); const panelCounterRef = useRef(0); const environmentProvider = useRelayEnvironmentProvider(); const openPageLoadPanel = useCallback( (reference: any) => { // Opening a panel replaces the current content. panelCounterRef.current += 1; const instanceId = `${baseId}-pageload-${panelCounterRef.current}`; manager.openPanel({ instanceId, entryPoint: panelEntryPoint, params: { conversation: 'main' }, entryPointReference: reference, }); }, [manager, baseId], ); const openNewPanel = useCallback(() => { panelCounterRef.current += 1; const instanceId = `${baseId}-new-${panelCounterRef.current}`; manager.openPanel({ instanceId, panelContent: <DummyEntryPointComponent />, params: { pageId: 'page-789' }, }); }, [manager, baseId]); // Open a panel on mount for demonstration useEffect(() => { // Mimic the behaviour of loading an entrypoint on page load const entryPointReference = loadEntryPoint<typeof panelEntryPoint>( environmentProvider, panelEntryPoint, {}, ); if (entryPointReference && state.activePanel === null) { openPageLoadPanel(entryPointReference); } }, [environmentProvider]); return ( <Flex xcss={styles.layout}> <Box xcss={styles.mainContent}> <Stack space="space.300"> <Heading size="medium">Panel Manager + PanelsRenderer Example</Heading> <Text> This example demonstrates using both the panel manager for programmatic control and PanelsRenderer for automatic rendering. Use the manager to open/close panels and read state, while PanelsRenderer handles rendering automatically. The `openPanel` method is used to ensure only one panel is open at a time (single panel mode). </Text> <Text>An EntryPoint panel opens by default on page load.</Text> <Stack space="space.200"> <Heading size="small" as="h3"> Actions </Heading> <Box xcss={styles.buttonGroup}> <Button onClick={openNewPanel} testId="open-new-button"> Open New Panel </Button> </Box> </Stack> <Stack space="space.200"> <Heading size="small" as="h3"> Active panel </Heading> {state.activePanel === null ? ( <Text color="color.text.subtle">No active panel</Text> ) : ( <Box xcss={styles.stateDisplay}> <pre> {JSON.stringify( { instanceId: state.activePanel.instanceId, entryPoint: 'entryPoint' in state.activePanel && state.activePanel.entryPoint ? getEntryPointName(state.activePanel.entryPoint) : 'N/A', params: state.activePanel.params, }, null, 2, )} </pre> </Box> )} </Stack> </Stack> </Box> <PanelsRenderer> <PanelRouter wrapper={({ children }: { children: React.ReactNode }) => ( <Box xcss={styles.panelWrapper}>{children}</Box> )} errorFallback={() => <Text as="p">Error loading panel</Text>} /> </PanelsRenderer> </Flex> ); } export default function PanelManagerExampleWithProvider(): React.JSX.Element { return ( <PanelProvider> <RelayEnvironmentWrapper> <PanelManagerExample /> </RelayEnvironmentWrapper> </PanelProvider> ); }

Custom panel rendering

You can customize how panels are rendered and prefetch panel EntryPoints ahead of time for a faster experience. This example uses useCurrentPanel together with the panel manager to render different panels from a shared renderer.

import React, { type Ref, useEffect, useRef } from 'react'; import Button from '@atlaskit/button/default/button'; import { cssMap } from '@atlaskit/css'; import { useId } from '@atlaskit/ds-lib/use-id'; import Heading from '@atlaskit/heading/heading'; import { Main } from '@atlaskit/navigation-system/layout/main'; import { Panel } from '@atlaskit/navigation-system/layout/panel'; import { PanelSplitter } from '@atlaskit/navigation-system/layout/panel-splitter'; import { Root } from '@atlaskit/navigation-system/layout/root'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { panelEntryPoint } from '@atlassian/entry-point-fixtures/panelEntryPoint'; import { usePanelManager } from '@atlassian/panel-system'; import { PanelActionCloseSmart } from '@atlassian/panel-system/panel-action/close-smart'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { useCurrentPanel } from '@atlassian/panel-system/panel-context'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; import { PanelsRenderer } from '@atlassian/panel-system/panels-renderer'; import { Trigger } from '@atlassian/panel-system/trigger'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ mainContent: { flex: 1, paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), }, buttonGroup: { display: 'flex', gap: token('space.100'), flexWrap: 'wrap', }, }); // Use real entry point from fixtures for prefetching demonstration const userPanelEntryPoint = panelEntryPoint; const settingsPanelEntryPoint = panelEntryPoint; const notificationsPanelEntryPoint = panelEntryPoint; type UserPanelParams = { userId: string; userName: string; }; function CustomUserPanel() { const panel = useCurrentPanel<UserPanelParams>(); const userId = panel.params.userId; const userName = panel.params.userName; return ( <PanelContainer> <PanelHeader> <PanelTitle>User Profile</PanelTitle> <PanelActionGroup> <PanelActionCloseSmart /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelBody> <Stack space="space.200"> <Heading size="small" as="h3"> {userName} </Heading> <Text color="color.text.subtle">ID: {userId}</Text> <Text size="small"> This panel was opened via context-based customisation. The component accesses panel data cleanly using the useCurrentPanel() hook. Drag the left edge to resize. </Text> </Stack> </PanelBody> </PanelContent> </PanelContainer> ); } type NotificationsPanelParams = { count: number; }; function NotificationsPanel() { const panel = useCurrentPanel<NotificationsPanelParams>(); const count = panel.params.count; return ( <PanelContainer> <PanelHeader> <PanelTitle>Notifications</PanelTitle> <PanelActionGroup> <PanelActionCloseSmart /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelBody> <Stack space="space.200"> <Heading size="small" as="h3"> You have {count} notifications </Heading> <Text size="small">Drag the left edge to resize this panel.</Text> </Stack> </PanelBody> </PanelContent> </PanelContainer> ); } type SettingsPanelParams = { category: string; }; function CustomSettingsPanel() { const panel = useCurrentPanel<SettingsPanelParams>(); const category = panel.params.category; return ( <PanelContainer> <PanelHeader> <PanelTitle>Settings</PanelTitle> <PanelActionGroup> <PanelActionCloseSmart /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelBody> <Stack space="space.200"> <Heading size="small" as="h3"> {category || 'General'} Settings </Heading> <Text size="small"> This is a custom panel rendering. You can build any UI you want using the panel UI components while accessing panel data via context. Drag the left edge to resize. </Text> </Stack> </PanelBody> </PanelContent> </PanelContainer> ); } function PanelRouter() { const panel = useCurrentPanel(); const { instanceId } = panel; if (instanceId.includes('user-panel')) { return <CustomUserPanel />; } if (instanceId.includes('settings-panel')) { return <CustomSettingsPanel />; } if (instanceId.includes('notifications-panel')) { return <NotificationsPanel />; } return null; } function CustomPanelRenderingExample() { const baseId = useId(); const { state, manager } = usePanelManager(); const hasOpenedRef = useRef(false); const isPanelOpen = state.activePanel !== null; // Open a panel on mount for demonstration useEffect(() => { if (!hasOpenedRef.current && state.activePanel === null) { hasOpenedRef.current = true; manager.openPanel({ instanceId: `${baseId}-user-panel`, entryPoint: userPanelEntryPoint, params: { userId: 'user-123', userName: 'Alice Johnson', }, }); } }, []); return ( <Root> <Main> <Box xcss={styles.mainContent}> <Stack space="space.300"> <Heading size="medium">Custom Panel Rendering with Context and Resizing</Heading> <Text> This example demonstrates the context-based customisation pattern with resizing. Each trigger opens a panel, and custom panel components use useCurrentPanel() to access their data. Drag the left edge of any panel to resize it. </Text> <Stack space="space.200"> <Heading size="small" as="h3"> Open Panels </Heading> <Text color="color.text.subtle" size="small"> Click to open different panel types. Each uses context-based customisation. </Text> <Box xcss={styles.buttonGroup}> <Trigger panel={userPanelEntryPoint} entryPointProps={{ userId: 'user-123', userName: 'Alice Johnson' }} instanceId={`${baseId}-user-panel`} > {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-user"> Open User Profile </Button> )} </Trigger> <Trigger panel={settingsPanelEntryPoint} entryPointProps={{ category: 'Preferences' }} instanceId={`${baseId}-settings-panel`} > {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-settings"> Open Settings </Button> )} </Trigger> <Trigger panel={notificationsPanelEntryPoint} entryPointProps={{ count: 5 }} instanceId={`${baseId}-notifications-panel`} > {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-notifications"> Open Notifications </Button> )} </Trigger> </Box> </Stack> </Stack> </Box> </Main> {isPanelOpen && ( <Panel defaultWidth={400} label="Panel"> <PanelsRenderer> <PanelRouter /> </PanelsRenderer> <PanelSplitter label="Resize panel" /> </Panel> )} </Root> ); } export default function CustomPanelRenderingExampleWithProvider(): React.JSX.Element { return ( <RelayEnvironmentWrapper> <PanelProvider> <CustomPanelRenderingExample /> </PanelProvider> </RelayEnvironmentWrapper> ); }

EntryPoint loading

Panels should load their content lazily through an EntryPoint, so the code for a panel is only fetched when it is opened. Use a Trigger with a panel EntryPoint to open a panel and render it through PanelsRenderer and PanelRouter.

import React, { type Ref } from 'react'; import Button from '@atlaskit/button/default/button'; import { cssMap } from '@atlaskit/css'; import Heading from '@atlaskit/heading/heading'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { panelEntryPoint } from '@atlassian/entry-point-fixtures/panelEntryPoint'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelRouter } from '@atlassian/panel-system/panel-router'; import { PanelsRenderer } from '@atlassian/panel-system/panels-renderer'; import { Trigger } from '@atlassian/panel-system/trigger'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ layout: { height: '100vh', width: '100%', overflow: 'hidden', }, mainContent: { flex: 1, minWidth: '0px', paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), overflow: 'auto', }, buttonGroup: { display: 'flex', gap: token('space.100'), flexWrap: 'wrap', }, panelWrapper: { borderInlineStart: `${token('border.width')} solid ${token('color.border')}`, flexShrink: 0, overflow: 'hidden', height: '100vh', display: 'flex', flexDirection: 'column', width: '400px', }, }); function EntryPointLoadingExample() { return ( <PanelProvider> <Flex xcss={styles.layout}> <Box xcss={styles.mainContent}> <Stack space="space.300"> <Heading size="medium">Entry Point Loading Example</Heading> <Text>This example demonstrates entry point loading using a simple entry point.</Text> <Stack space="space.200"> <Heading size="small" as="h3"> Open Panel </Heading> <Text color="color.text.subtle" size="small"> Click to open a panel. The panel loads the entry point. </Text> <Box xcss={styles.buttonGroup}> <Trigger panel={panelEntryPoint} params={{}}> {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-panel"> Open Panel </Button> )} </Trigger> </Box> </Stack> </Stack> </Box> <PanelsRenderer> <PanelRouter wrapper={({ children }: { children: React.ReactNode }) => ( <Box xcss={styles.panelWrapper}>{children}</Box> )} errorFallback={() => <Text as="p">Error loading panel</Text>} /> </PanelsRenderer> </Flex> </PanelProvider> ); } export default function EntryPointLoadingExampleWithProvider(): React.JSX.Element { return ( <RelayEnvironmentWrapper> <EntryPointLoadingExample /> </RelayEnvironmentWrapper> ); }

Loading methods

There are several ways to open a panel: declaratively with a Trigger, or imperatively through the panel manager (for example manager.openPanel). This example demonstrates the different loading methods side by side.

Trigger is the recommended loading method as it handles preloading on hover/focus automatically.

import React, { type Ref } from 'react'; import Button from '@atlaskit/button/default/button'; import Code from '@atlaskit/code/code'; import { cssMap } from '@atlaskit/css'; import Heading from '@atlaskit/heading/heading'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Inline } from '@atlaskit/primitives/compiled/inline'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { usePanelManager } from '@atlassian/panel-system'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelRouter } from '@atlassian/panel-system/panel-router'; import { PanelsRenderer } from '@atlassian/panel-system/panels-renderer'; import { Trigger } from '@atlassian/panel-system/trigger'; import { panelEntryPoint } from '../utils/dummy-entrypoint'; import DummyEntryPointComponent from '../utils/dummy-entrypoint-component'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ layout: { height: '100vh', width: '100%', overflow: 'hidden', }, mainContent: { flex: 1, minWidth: '0px', paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), overflow: 'auto', }, panelWrapper: { borderInlineStart: `${token('border.width')} solid ${token('color.border')}`, flexShrink: 0, overflow: 'hidden', height: '100vh', display: 'flex', flexDirection: 'column', width: '400px', }, }); function LoadingMethodsExample() { const { manager } = usePanelManager(); const onClickWithoutEntrypoint = () => { manager.openPanel({ instanceId: 'panelWithoutEntryPoint', panelContent: <DummyEntryPointComponent />, }); }; return ( <Flex xcss={styles.layout}> <Box xcss={styles.mainContent}> <Stack space="space.300"> <Heading size="medium">All loading methods</Heading> <Text>This example demonstrates all ways to load a panel</Text> <Stack space="space.200"> <Heading size="small" as="h3"> Open Panel </Heading> <Text color="color.text.subtle" size="small"> Click to open a panel. The panel loads the entry point. </Text> <Stack space="space.200"> <Inline space="space.100" alignBlock="baseline"> <Text>Open panel via Trigger</Text> <Trigger panel={panelEntryPoint} params={{}}> {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-panel-1"> Open panel </Button> )} </Trigger> </Inline> <Inline space="space.100" alignBlock="baseline"> <Text> Open panel via <Code>manager.openPanel()</Code> </Text> <Button onClick={onClickWithoutEntrypoint} testId="open-panel-3"> Open panel </Button> </Inline> </Stack> </Stack> </Stack> </Box> <PanelsRenderer> <PanelRouter wrapper={({ children }: { children: React.ReactNode }) => ( <Box xcss={styles.panelWrapper}>{children}</Box> )} errorFallback={() => <Text as="p">Error loading panel</Text>} /> </PanelsRenderer> </Flex> ); } export default function LoadingMethodsExampleWithProvider(): React.JSX.Element { return ( <PanelProvider> <RelayEnvironmentWrapper> <LoadingMethodsExample /> </RelayEnvironmentWrapper> </PanelProvider> ); }

Unsaved changes

Use the onBeforeClose callback to intercept a close request. Return false to keep the panel open — for example, to show a confirmation dialog when there are unsaved changes.

import React, { type Ref, useCallback, useState } from 'react'; import ButtonGroup from '@atlaskit/button/button-group'; import Button from '@atlaskit/button/default/button'; import { cssMap } from '@atlaskit/css'; import Heading from '@atlaskit/heading/heading'; import ModalBody from '@atlaskit/modal-dialog/modal-body'; import Modal from '@atlaskit/modal-dialog/modal-dialog'; import ModalFooter from '@atlaskit/modal-dialog/modal-footer'; import ModalHeader from '@atlaskit/modal-dialog/modal-header'; import ModalTitle from '@atlaskit/modal-dialog/modal-title'; import ModalTransition from '@atlaskit/modal-dialog/modal-transition'; import { Box } from '@atlaskit/primitives/compiled/box'; import { Flex } from '@atlaskit/primitives/compiled/flex'; import { Stack } from '@atlaskit/primitives/compiled/stack'; import { Text } from '@atlaskit/primitives/compiled/text'; import { token } from '@atlaskit/tokens'; import { panelEntryPoint } from '@atlassian/entry-point-fixtures/panelEntryPoint'; import { PanelActionClose } from '@atlassian/panel-system/panel-action/close'; import { PanelActionGroup } from '@atlassian/panel-system/panel-action/group'; import { PanelBody } from '@atlassian/panel-system/panel-body'; import { PanelContainer } from '@atlassian/panel-system/panel-container'; import { PanelContent } from '@atlassian/panel-system/panel-content'; import { useCloseCurrentPanel } from '@atlassian/panel-system/panel-context'; import { PanelFooter } from '@atlassian/panel-system/panel-footer'; import { PanelHeader } from '@atlassian/panel-system/panel-header'; import { PanelProvider } from '@atlassian/panel-system/panel-provider'; import { PanelTitle } from '@atlassian/panel-system/panel-title'; import { PanelsRenderer } from '@atlassian/panel-system/panels-renderer'; import { Trigger } from '@atlassian/panel-system/trigger'; import { RelayEnvironmentWrapper } from '../utils/relay-environment-wrapper'; const styles = cssMap({ layout: { height: '100vh', width: '100%', }, mainContent: { flex: 1, paddingBlockStart: token('space.300'), paddingInlineEnd: token('space.300'), paddingBlockEnd: token('space.300'), paddingInlineStart: token('space.300'), backgroundColor: token('color.background.neutral.subtle'), }, buttonGroup: { display: 'flex', gap: token('space.100'), flexWrap: 'wrap', }, panelWrapper: { borderInlineStart: `${token('border.width')} solid ${token('color.border')}`, width: '480px', }, }); /** * Panel component that demonstrates unsaved changes handling with onBeforeClose. * This shows how to prevent panel closing when there are unsaved changes, and how * to show a confirmation dialog using @atlaskit/modal-dialog. */ function UnsavedChangesPanel() { const closePanel = useCloseCurrentPanel(); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [formValue, setFormValue] = useState(''); const [showDiscardModal, setShowDiscardModal] = useState(false); const handleBeforeClose = async () => { if (hasUnsavedChanges) { // Show the discard confirmation modal instead of using window.confirm setShowDiscardModal(true); return false; // Prevent panel from closing } return true; }; const handleDiscard = useCallback(() => { setShowDiscardModal(false); setHasUnsavedChanges(false); setFormValue(''); closePanel(); }, [closePanel]); const handleCancelDiscard = useCallback(() => { setShowDiscardModal(false); }, []); const handleSave = () => { setHasUnsavedChanges(false); }; const handleCancel = () => { setFormValue(''); setHasUnsavedChanges(false); }; return ( <> <Box xcss={styles.panelWrapper}> <PanelContainer testId="unsaved-changes-panel"> <PanelHeader> <PanelTitle>Edit Form</PanelTitle> <PanelActionGroup> <PanelActionClose onBeforeClose={handleBeforeClose} onClick={closePanel} testId="close-button" /> </PanelActionGroup> </PanelHeader> <PanelContent> <PanelBody> <Stack space="space.200"> <Text> This example demonstrates handling unsaved changes when closing a panel using{' '} <code>onBeforeClose</code> with <code>@atlaskit/modal-dialog</code>. </Text> <Text color="color.text.subtle" size="small"> Try editing the field below, then attempt to close the panel. You'll see a modal dialog asking if you want to discard your changes. </Text> <div> <label htmlFor="form-field"> <Text as="span" size="small" weight="medium"> Field Value: </Text> </label> <input id="form-field" type="text" value={formValue} onChange={(e) => { setFormValue(e.target.value); setHasUnsavedChanges(true); }} /> </div> {hasUnsavedChanges && ( <Text color="color.text.warning" size="small"> ⚠️ You have unsaved changes. Try closing the panel to see the confirmation modal. </Text> )} </Stack> </PanelBody> </PanelContent> <PanelFooter> <ButtonGroup label="Panel actions"> <Button appearance="subtle" onClick={handleCancel}> Cancel </Button> <Button appearance="primary" onClick={handleSave} isDisabled={!hasUnsavedChanges}> Save </Button> </ButtonGroup> </PanelFooter> </PanelContainer> </Box> <ModalTransition> {showDiscardModal && ( <Modal onClose={handleCancelDiscard} testId="discard-modal" width="small"> <ModalHeader hasCloseButton> <ModalTitle appearance="danger" isMultiline={false}> Discard your changes? </ModalTitle> </ModalHeader> <ModalBody> <Box as="p">If you leave, your changes will be lost.</Box> </ModalBody> <ModalFooter> <Button appearance="subtle" onClick={handleCancelDiscard}> Cancel </Button> <Button appearance="danger" onClick={handleDiscard}> Discard changes </Button> </ModalFooter> </Modal> )} </ModalTransition> </> ); } function UnsavedChangesExample() { return ( <Flex xcss={styles.layout}> <Box xcss={styles.mainContent}> <Stack space="space.300"> <Heading size="medium">Unsaved Changes Example</Heading> <Text> This example demonstrates handling unsaved changes when closing a panel using the{' '} <code>onBeforeClose</code> callback. </Text> <Stack space="space.200"> <Heading size="small" as="h3"> How it works </Heading> <Text color="color.text.subtle" size="small"> The <code>onBeforeClose</code> callback is called before the panel closes. If it returns <code>false</code> (or a Promise resolving to <code>false</code>), the close action is prevented. This example uses <code>@atlaskit/modal-dialog</code> to show a discard confirmation modal instead of using <code>window.confirm</code>. </Text> <Box xcss={styles.buttonGroup}> <Trigger panel={panelEntryPoint} params={{}}> {({ ref }) => ( <Button ref={ref as Ref<HTMLButtonElement>} testId="open-panel"> Open Edit Form </Button> )} </Trigger> </Box> </Stack> </Stack> </Box> <PanelsRenderer> <UnsavedChangesPanel /> </PanelsRenderer> </Flex> ); } export default function UnsavedChangesExampleWithProvider(): React.JSX.Element { return ( <RelayEnvironmentWrapper> <PanelProvider> <UnsavedChangesExample /> </PanelProvider> </RelayEnvironmentWrapper> ); }
Was this page helpful?
We use this feedback to improve our documentation.
© 2026 AtlassianTrademark, (opens new window)Privacy, (opens new window)License