Skip to content
8 changes: 8 additions & 0 deletions static/app/components/core/splitPanel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export {
SplitPanel,
type SplitPanelDividerProps,
type SplitPanelPanelProps,
type SplitPanelProps,
useSplitPanel,
useSplitPanelDivider,
} from './splitPanel';
266 changes: 266 additions & 0 deletions static/app/components/core/splitPanel/splitPanel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
---
title: SplitPanel
description: A resizable two-pane layout with a draggable, keyboard-accessible divider, supporting horizontal (left/right) and vertical (top/bottom) orientations.
category: layout
source: '@sentry/scraps/splitPanel'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/splitPanel/splitPanel.tsx
---

import {useState} from 'react';

import {Flex} from '@sentry/scraps/layout';
import {SplitPanel} from '@sentry/scraps/splitPanel';
import {Text} from '@sentry/scraps/text';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/splitPanel');

export function HorizontalSplitDemo() {
const CONTAINER_WIDTH = 600;
const [size, setSize] = useState(CONTAINER_WIDTH / 2);
const leftPct = (size / CONTAINER_WIDTH) * 100;
const rightPct = 100 - leftPct;
return (
<Flex direction="column" gap="md">
<Flex
border="primary"
radius="md"
overflow="hidden"
style={{width: CONTAINER_WIDTH, height: 160}}
>
<SplitPanel orientation="horizontal" onResize={setSize}>
<SplitPanel.Panel defaultSize={CONTAINER_WIDTH / 2} minSize={120}>
<Flex align="center" justify="center" flex="1" background="primary">
<Text>Left</Text>
</Flex>
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<Flex align="center" justify="center" flex="1" background="primary">
<Text>Right</Text>
</Flex>
</SplitPanel.Panel>
</SplitPanel>
</Flex>
<Text variant="muted">
Left: {leftPct.toFixed(1)}% | Right: {rightPct.toFixed(1)}%
</Text>
</Flex>
);
}

export function VerticalSplitDemo() {
const CONTAINER_HEIGHT = 240;
const [size, setSize] = useState(CONTAINER_HEIGHT / 2);
const topPct = (size / CONTAINER_HEIGHT) * 100;
const bottomPct = 100 - topPct;
return (
<Flex direction="column" gap="md">
<Flex
border="primary"
radius="md"
overflow="hidden"
style={{width: 600, height: CONTAINER_HEIGHT}}
>
<SplitPanel orientation="vertical" onResize={setSize}>
<SplitPanel.Panel defaultSize={CONTAINER_HEIGHT / 2} minSize={60}>
<Flex align="center" justify="center" flex="1" background="primary">
<Text>Top</Text>
</Flex>
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<Flex align="center" justify="center" flex="1" background="primary">
<Text>Bottom</Text>
</Flex>
</SplitPanel.Panel>
</SplitPanel>
</Flex>
<Text variant="muted">
Top: {topPct.toFixed(1)}% | Bottom: {bottomPct.toFixed(1)}%
</Text>
</Flex>
);
}

`SplitPanel` is a composable two-pane layout with a draggable divider. The pane that declares `defaultSize` is the sized pane; the other fills the remaining space. The root self-measures, so callers don't need to thread the container width or height in.

## Basic Usage

```tsx
<SplitPanel orientation="horizontal">
<SplitPanel.Panel defaultSize={300} minSize={200} maxSize={600}>
<LeftContent />
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<RightContent />
</SplitPanel.Panel>
</SplitPanel>
```

The composable shape makes the call site read top-to-bottom: first pane, divider, second pane. There's no `availableSize` prop — the root measures itself with `useDimensions`.

## Horizontal Split

The first pane is sized; the second fills.

<Storybook.Demo>
<HorizontalSplitDemo />
</Storybook.Demo>

```tsx
<SplitPanel orientation="horizontal">
<SplitPanel.Panel defaultSize={300} minSize={120}>
<LeftContent />
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<RightContent />
</SplitPanel.Panel>
</SplitPanel>
```

## Vertical Split

Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one.

<Storybook.Demo>
<VerticalSplitDemo />
</Storybook.Demo>

```tsx
<SplitPanel orientation="vertical">
<SplitPanel.Panel defaultSize={120} minSize={60}>
<TopContent />
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<BottomContent />
</SplitPanel.Panel>
</SplitPanel>
```

## Collapsing a Pane

To collapse the fill pane, omit the `<SplitPanel.Divider>` and second `<SplitPanel.Panel>`. The sized pane's DOM identity is preserved across collapses, so its content state is not lost.

```tsx
<SplitPanel orientation="horizontal">
<SplitPanel.Panel defaultSize={300} minSize={200} maxSize={600}>
<LeftContent />
</SplitPanel.Panel>
{!isCollapsed && (
<>
<SplitPanel.Divider />
<SplitPanel.Panel>
<RightContent />
</SplitPanel.Panel>
</>
)}
</SplitPanel>
```

## Persisting Size

`SplitPanel` is a layout primitive — persistence is the consumer's job. Wire `onResize` to your own storage (`useLocalStorageState`, a database, a query param, etc.) and feed the stored value back as `defaultSize`.

```tsx
import {useLocalStorageState} from 'sentry/utils/useLocalStorageState';

function ResizableLayout() {
const [storedSize, setStoredSize] = useLocalStorageState('my-feature.split-size', 300);

return (
<SplitPanel orientation="horizontal" onResize={setStoredSize}>
<SplitPanel.Panel defaultSize={storedSize} minSize={200} maxSize={600}>
<LeftContent />
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<RightContent />
</SplitPanel.Panel>
</SplitPanel>
);
}
```

## Custom Divider

For most cases, use `<SplitPanel.Divider>` — a thin 1px line matching the rest of the app. If you need a different visual (e.g. a grab handle, a thicker bar, a colored line for a critical section), build your own divider with the `useSplitPanelDivider` hook. It returns the ARIA props, event handlers, and `isHeld` state needed to make any element behave like the divider.

```tsx
import styled from '@emotion/styled';
import {useSplitPanelDivider} from '@sentry/scraps/splitPanel';

function GrabHandleDivider() {
const {props, isHeld} = useSplitPanelDivider();
return (
<GrabHandle {...props} data-is-held={isHeld}>
<IconGrabbable size="sm" />
</GrabHandle>
);
}

const GrabHandle = styled('div')`
display: grid;
place-items: center;
width: ${p => p.theme.space.xl};
cursor: ew-resize;
`;
```

Then drop your component in place of `<SplitPanel.Divider>` inside the `<SplitPanel>` children.

## Tracking Resize Events

`onMouseDown` fires when the user starts dragging (receives the current size as a percentage). `onResize` fires as the size changes (receives the new size in pixels). Use these for analytics or to drive linked UI.

```tsx
<SplitPanel
orientation="horizontal"
onMouseDown={sizePct => trackStart(sizePct)}
onResize={newSize => trackEnd(newSize)}
>
<SplitPanel.Panel defaultSize={300} minSize={200} maxSize={600}>
<LeftContent />
</SplitPanel.Panel>
<SplitPanel.Divider />
<SplitPanel.Panel>
<RightContent />
</SplitPanel.Panel>
</SplitPanel>
```

## Imperative Controls

Use the `useSplitPanel` hook from any descendant to maximise, minimise, or reset the sized pane.

```tsx
import {useSplitPanel} from '@sentry/scraps/splitPanel';

function PaneToolbar() {
const {maximiseSize, minimiseSize, resetSize, isMaximized, isMinimized} =
useSplitPanel();

return (
<Flex gap="sm">
<Button onClick={minimiseSize} disabled={isMinimized}>
Collapse
</Button>
<Button onClick={resetSize}>Reset</Button>
<Button onClick={maximiseSize} disabled={isMaximized}>
Expand
</Button>
</Flex>
);
}
```

## Accessibility

- The divider has `role="separator"` with `aria-orientation`, `aria-valuemin`, `aria-valuemax`, and `aria-valuenow` reflecting the current size.
- The divider is focusable (`tabIndex={0}`) and supports keyboard resize: ← / → for horizontal, ↑ / ↓ for vertical, with `Shift` for a coarser step. `Home` snaps to `minSize`; `End` snaps to `maxSize`.
- Double-clicking the divider resets the sized pane to its `defaultSize`.
Loading
Loading