@@ -361,6 +383,129 @@ export const WithCustomPaneHeading: StoryFn = () => (
)
+export const SidebarStart: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarEnd: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const ResizableSidebar: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarWithPaneResizable: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const StickySidebar: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
+
+export const SidebarFullscreenWhenNarrow: StoryFn = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
export const ResizablePaneWithoutPersistence: StoryFn = () => {
const [currentWidth, setCurrentWidth] = React.useState
(defaultPaneWidth.medium)
diff --git a/packages/react/src/PageLayout/PageLayout.module.css b/packages/react/src/PageLayout/PageLayout.module.css
index 5466fce1d67..8a1cae8430e 100644
--- a/packages/react/src/PageLayout/PageLayout.module.css
+++ b/packages/react/src/PageLayout/PageLayout.module.css
@@ -596,7 +596,7 @@
width: 100%;
@media screen and (min-width: 768px) {
- /*
+ /*
* --pane-max-width is set by JS on mount and updated on resize (debounced).
* JS calculates viewport - margin to avoid scrollbar discrepancy with 100vw.
*/
@@ -742,3 +742,134 @@
contain: layout style paint;
pointer-events: none;
}
+
+/* Sidebar */
+.PageLayoutRoot:where([data-has-sidebar]) {
+ /* Note: Sidebar styles are only applied when the PageLayout has a Sidebar child
+ via `[data-has-sidebar`]` on the root element
+ */
+ display: flex;
+ /*
+ Current layout structure for Sidebar support:
+ -- [Sidebar] | [Header + Content + Footer] | [Sidebar] --
+ */
+ flex-direction: row;
+}
+
+.SidebarWrapper {
+ /* Current layout structure:
+ -- [Sidebar] | [Resizable Divider] --
+ */
+ display: flex;
+ flex-direction: row;
+ flex-shrink: 0;
+ height: 100%;
+
+ &:where([data-is-hidden='true']) {
+ display: none;
+ }
+
+ /* Position: start (left side) */
+ &:where([data-position='start']) {
+ order: -1;
+ }
+
+ /* Position: end (right side) */
+ &:where([data-position='end']) {
+ order: 1;
+ }
+
+ /* Sticky sidebar */
+ &:where([data-sticky]) {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ }
+
+ /* Narrow viewport */
+ @media (--viewportRange-narrow) {
+ &:where([data-is-hidden-narrow='true']) {
+ display: none;
+ }
+
+ /* Fullscreen mode at narrow viewport */
+ &:where([data-when-narrow='fullscreen']) {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ min-width: unset;
+ max-width: unset;
+ border-radius: 0;
+ /* stylelint-disable-next-line primer/spacing */
+ z-index: 999;
+ background-color: var(--bgColor-default);
+ }
+ }
+
+ /* Regular viewport */
+ @media (--viewportRange-regular) {
+ &:where([data-is-hidden-regular='true']) {
+ display: none;
+ }
+ }
+
+ /* Wide viewport */
+ @media (--viewportRange-wide) {
+ &:where([data-is-hidden-wide='true']) {
+ display: none;
+ }
+ }
+}
+
+.Sidebar {
+ width: var(--pane-width-size);
+ /* stylelint-disable-next-line primer/spacing */
+ padding: var(--spacing);
+ height: 100%;
+ overflow: auto;
+
+ &:where([data-resizable]) {
+ width: 100%;
+
+ @media screen and (min-width: 768px) {
+ width: clamp(var(--pane-min-width), var(--pane-width), var(--pane-max-width));
+ }
+ }
+
+ /* In fullscreen mode at narrow, sidebar takes full available space */
+ @media (--viewportRange-narrow) {
+ :where([data-when-narrow='fullscreen']) > & {
+ width: 100%;
+ height: 100%;
+ min-width: unset;
+ max-width: unset;
+ }
+ }
+}
+
+.SidebarVerticalDivider {
+ /* Base position (non-responsive) */
+ &:where([data-position='start']) {
+ /* stylelint-disable-next-line primer/spacing */
+ margin-left: var(--spacing);
+ }
+
+ &:where([data-position='end']) {
+ /* stylelint-disable-next-line primer/spacing */
+ margin-right: var(--spacing);
+ }
+
+ /* Hide divider in fullscreen mode at narrow viewport */
+ @media (--viewportRange-narrow) {
+ :where([data-when-narrow='fullscreen']) > & {
+ display: none;
+ }
+ }
+}
+
+.Sidebar[data-dragging='true'] {
+ contain: layout style paint;
+ pointer-events: none;
+}
diff --git a/packages/react/src/PageLayout/PageLayout.tsx b/packages/react/src/PageLayout/PageLayout.tsx
index c20e93049ff..f0dea3bd76c 100644
--- a/packages/react/src/PageLayout/PageLayout.tsx
+++ b/packages/react/src/PageLayout/PageLayout.tsx
@@ -18,6 +18,8 @@ import {
isPaneWidth,
ARROW_KEY_STEP,
type PaneWidthValue,
+ type PaneWidth,
+ type CustomWidthOptions,
} from './usePaneWidth'
import {setDraggingStyles, removeDraggingStyles} from './paneUtils'
@@ -46,12 +48,16 @@ const PageLayoutContext = React.createContext<{
columnGap: keyof typeof SPACING_MAP
paneRef: React.RefObject
contentWrapperRef: React.RefObject
+ sidebarRef: React.RefObject
+ sidebarContentWrapperRef: React.RefObject
}>({
padding: 'normal',
rowGap: 'normal',
columnGap: 'normal',
paneRef: {current: null},
contentWrapperRef: {current: null},
+ sidebarRef: {current: null},
+ sidebarContentWrapperRef: {current: null},
})
// ----------------------------------------------------------------------------
@@ -66,7 +72,7 @@ export type PageLayoutProps = {
columnGap?: keyof typeof SPACING_MAP
/** Private prop to allow SplitPageLayout to customize slot components */
- _slotsConfig?: Record<'header' | 'footer', React.ElementType>
+ _slotsConfig?: Record<'header' | 'footer' | 'sidebar', React.ElementType>
className?: string
style?: React.CSSProperties
}
@@ -92,8 +98,10 @@ const Root: React.FC> = ({
}) => {
const paneRef = useRef(null)
const contentWrapperRef = useRef(null)
+ const sidebarRef = useRef(null)
+ const sidebarContentWrapperRef = useRef(null)
- const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer})
+ const [slots, rest] = useSlots(children, slotsConfig ?? {header: Header, footer: Footer, sidebar: Sidebar})
const memoizedContextValue = React.useMemo(() => {
return {
@@ -102,12 +110,15 @@ const Root: React.FC> = ({
columnGap,
paneRef,
contentWrapperRef,
+ sidebarRef,
+ sidebarContentWrapperRef,
}
- }, [padding, rowGap, columnGap, paneRef, contentWrapperRef])
+ }, [padding, rowGap, columnGap, paneRef, contentWrapperRef, sidebarRef, sidebarContentWrapperRef])
return (
-
+
+ {slots.sidebar}
{slots.header}
{rest}
@@ -124,7 +135,8 @@ const RootWrapper = memo(
padding,
children,
className,
- }: React.PropsWithChildren
>) => {
+ hasSidebar,
+ }: React.PropsWithChildren & {hasSidebar?: boolean}>) => {
return (
{children}
@@ -196,6 +209,114 @@ const VerticalDivider = memo>(
VerticalDivider.displayName = 'VerticalDivider'
+type SidebarDividerProps = {
+ position: 'start' | 'end'
+ divider: 'none' | 'line'
+ resizable: boolean
+ minPaneWidth: number
+ maxPaneWidth: number
+ currentWidth: number
+ currentWidthRef: React.MutableRefObject
+ handleRef: React.RefObject
+ sidebarRef: React.RefObject
+ dragStartClientXRef: React.MutableRefObject
+ dragStartWidthRef: React.MutableRefObject
+ dragMaxWidthRef: React.MutableRefObject
+ getMaxPaneWidth: () => number
+ getDefaultWidth: () => number
+ saveWidth: (width: number) => void
+}
+
+const SidebarDivider = memo(function SidebarDivider({
+ position,
+ divider,
+ resizable,
+ minPaneWidth,
+ maxPaneWidth,
+ currentWidth,
+ currentWidthRef,
+ handleRef,
+ sidebarRef,
+ dragStartClientXRef,
+ dragStartWidthRef,
+ dragMaxWidthRef,
+ getMaxPaneWidth,
+ getDefaultWidth,
+ saveWidth,
+}) {
+ const {columnGap} = React.useContext(PageLayoutContext)
+
+ return (
+
+ {resizable ? (
+ {
+ dragStartClientXRef.current = clientX
+ dragStartWidthRef.current = sidebarRef.current?.getBoundingClientRect().width ?? currentWidthRef.current!
+ dragMaxWidthRef.current = getMaxPaneWidth()
+ }}
+ onDrag={(value, isKeyboard) => {
+ const maxWidth = isKeyboard ? getMaxPaneWidth() : dragMaxWidthRef.current
+
+ if (isKeyboard) {
+ // For position='end': invert the delta so arrow keys feel natural
+ // ArrowRight should shrink (move divider right), ArrowLeft should expand
+ const delta = position === 'end' ? -value : value
+ const newWidth = Math.max(minPaneWidth, Math.min(maxWidth, currentWidthRef.current! + delta))
+ if (newWidth !== currentWidthRef.current) {
+ currentWidthRef.current = newWidth
+ sidebarRef.current?.style.setProperty('--pane-width', `${newWidth}px`)
+ updateAriaValues(handleRef.current, {current: newWidth, max: maxWidth})
+ }
+ } else {
+ if (sidebarRef.current) {
+ const deltaX = value - dragStartClientXRef.current
+ // For position='end': cursor moving left (negative delta) increases width
+ // For position='start': cursor moving right (positive delta) increases width
+ const directedDelta = position === 'end' ? -deltaX : deltaX
+ const newWidth = dragStartWidthRef.current + directedDelta
+
+ const clampedWidth = Math.max(minPaneWidth, Math.min(maxWidth, newWidth))
+
+ if (Math.round(clampedWidth) !== Math.round(currentWidthRef.current!)) {
+ sidebarRef.current.style.setProperty('--pane-width', `${clampedWidth}px`)
+ currentWidthRef.current = clampedWidth
+ updateAriaValues(handleRef.current, {current: Math.round(clampedWidth), max: maxWidth})
+ }
+ }
+ }
+ }}
+ onDragEnd={() => {
+ saveWidth(currentWidthRef.current!)
+ }}
+ onDoubleClick={() => {
+ const resetWidth = getDefaultWidth()
+ if (sidebarRef.current) {
+ sidebarRef.current.style.setProperty('--pane-width', `${resetWidth}px`)
+ currentWidthRef.current = resetWidth
+ updateAriaValues(handleRef.current, {current: resetWidth})
+ }
+ saveWidth(resetWidth)
+ }}
+ />
+ ) : null}
+
+ )
+})
+
type DragHandleProps = {
/** Ref for imperative ARIA updates during drag */
handleRef: React.RefObject
@@ -918,6 +1039,237 @@ Pane.displayName = 'PageLayout.Pane'
// ----------------------------------------------------------------------------
// PageLayout.Footer
+// ----------------------------------------------------------------------------
+// PageLayout.Sidebar
+
+export type PageLayoutSidebarProps = {
+ /**
+ * A unique label for the sidebar region
+ */
+ 'aria-label'?: React.AriaAttributes['aria-label']
+
+ /**
+ * An id to an element which uniquely labels the sidebar region
+ */
+ 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']
+
+ /**
+ * Position of the sidebar relative to the page layout
+ * @default 'start'
+ */
+ position?: 'start' | 'end'
+
+ /**
+ * Width configuration for the sidebar
+ */
+ width?: PaneWidth | CustomWidthOptions
+
+ /**
+ * Minimum width of the sidebar when resizable
+ * @default 256
+ */
+ minWidth?: number
+
+ /**
+ * Whether the sidebar can be resized
+ * @default false
+ */
+ resizable?: boolean
+
+ /**
+ * Storage key for persisting the sidebar width
+ * @default 'sidebarWidth'
+ */
+ widthStorageKey?: string
+
+ /**
+ * Padding inside the sidebar
+ */
+ padding?: keyof typeof SPACING_MAP
+
+ /**
+ * Divider style between sidebar and content
+ */
+ divider?: 'none' | 'line'
+
+ /**
+ * Whether the sidebar sticks to the viewport when scrolling.
+ * When enabled, the sidebar uses `position: sticky` with `top: 0` and `height: 100vh`.
+ * @default false
+ */
+ sticky?: boolean
+
+ /**
+ * Controls sidebar behavior at narrow viewport widths (below 768px).
+ * - `'default'`: the sidebar retains its normal inline layout.
+ * - `'fullscreen'`: the sidebar expands to cover the full viewport like a dialog overlay.
+ * @default 'default'
+ */
+ whenNarrow?: 'default' | 'fullscreen'
+
+ /**
+ * Whether the sidebar is hidden
+ */
+ hidden?: boolean | ResponsiveValue
+
+ /**
+ * Optional id for the sidebar element
+ */
+ id?: string
+
+ className?: string
+ style?: React.CSSProperties
+}
+
+const Sidebar = React.forwardRef>(
+ (
+ {
+ 'aria-label': label,
+ 'aria-labelledby': labelledBy,
+ position = 'start',
+ width = 'medium',
+ minWidth = 256,
+ padding = 'none',
+ resizable = false,
+ widthStorageKey = 'sidebarWidth',
+ divider = 'none',
+ sticky = false,
+ whenNarrow = 'default',
+ hidden: responsiveHidden = false,
+ children,
+ id,
+ className,
+ style,
+ },
+ forwardRef,
+ ) => {
+ const {columnGap, sidebarRef, sidebarContentWrapperRef} = React.useContext(PageLayoutContext)
+
+ // Ref to the drag handle for updating ARIA attributes
+ const handleRef = React.useRef(null)
+
+ // Cache drag start values to calculate relative delta during drag
+ const dragStartClientXRef = React.useRef(0)
+ const dragStartWidthRef = React.useRef(0)
+ const dragMaxWidthRef = React.useRef(0)
+
+ const {currentWidth, currentWidthRef, minPaneWidth, maxPaneWidth, getMaxPaneWidth, saveWidth, getDefaultWidth} =
+ usePaneWidth({
+ width,
+ minWidth,
+ resizable,
+ widthStorageKey,
+ paneRef: sidebarRef,
+ handleRef,
+ contentWrapperRef: sidebarContentWrapperRef,
+ constrainToViewport: true,
+ })
+
+ useRefObjectAsForwardedRef(forwardRef, sidebarRef)
+
+ const hasOverflow = useOverflow(sidebarRef)
+
+ const sidebarId = useId(id)
+
+ const labelProp: {'aria-labelledby'?: string; 'aria-label'?: string} = {}
+ if (hasOverflow) {
+ warning(
+ label === undefined && labelledBy === undefined,
+ 'The has overflow and `aria-label` or `aria-labelledby` has not been set. ' +
+ 'Please provide `aria-label` or `aria-labelledby` to in order to label this ' +
+ 'region.',
+ )
+
+ if (labelledBy) {
+ labelProp['aria-labelledby'] = labelledBy
+ } else if (label) {
+ labelProp['aria-label'] = label
+ }
+ }
+
+ return (
+
+ {position === 'end' && (
+
+ )}
+
+ {position === 'start' && (
+
+ )}
+
+ )
+ },
+)
+
+Sidebar.displayName = 'PageLayout.Sidebar'
+
+// ----------------------------------------------------------------------------
+// PageLayout.Footer
+
export type PageLayoutFooterProps = {
/**
* A unique label for the rendered contentinfo landmark
@@ -1014,10 +1366,12 @@ export const PageLayout = Object.assign(Root, {
Header,
Content,
Pane: Pane as WithSlotMarker,
+ Sidebar: Sidebar as WithSlotMarker,
Footer,
})
Header.__SLOT__ = Symbol('PageLayout.Header')
Content.__SLOT__ = Symbol('PageLayout.Content')
;(Pane as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Pane')
+;(Sidebar as WithSlotMarker).__SLOT__ = Symbol('PageLayout.Sidebar')
Footer.__SLOT__ = Symbol('PageLayout.Footer')
diff --git a/packages/react/src/PageLayout/usePaneWidth.ts b/packages/react/src/PageLayout/usePaneWidth.ts
index b52b99fdd41..00a9240c63f 100644
--- a/packages/react/src/PageLayout/usePaneWidth.ts
+++ b/packages/react/src/PageLayout/usePaneWidth.ts
@@ -30,6 +30,12 @@ export type UsePaneWidthOptions = {
paneRef: React.RefObject
handleRef: React.RefObject
contentWrapperRef: React.RefObject
+ /**
+ * When true, custom max width values are capped to the viewport-based max.
+ * This prevents overflow in non-wrapping flex layouts (e.g., Sidebar).
+ * @default false
+ */
+ constrainToViewport?: boolean
/** Callback fired when a resize operation ends (drag release or keyboard key up) */
onResizeEnd?: (width: number) => void
/** Current/controlled width value in pixels (used instead of internal state; default from `width` is still used for reset) */
@@ -125,6 +131,35 @@ export const updateAriaValues = (
}
}
+/**
+ * Measures the available space for a pane/sidebar by examining its parent flex
+ * container and subtracting the widths of sibling elements.
+ *
+ * Navigation: paneElement (