diff --git a/static/app/components/core/splitPanel/index.tsx b/static/app/components/core/splitPanel/index.tsx
new file mode 100644
index 00000000000000..cba4aaf7ca914e
--- /dev/null
+++ b/static/app/components/core/splitPanel/index.tsx
@@ -0,0 +1,8 @@
+export {
+ SplitPanel,
+ type SplitPanelDividerProps,
+ type SplitPanelPanelProps,
+ type SplitPanelProps,
+ useSplitPanel,
+ useSplitPanelDivider,
+} from './splitPanel';
diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx
new file mode 100644
index 00000000000000..62dae652f4bb45
--- /dev/null
+++ b/static/app/components/core/splitPanel/splitPanel.mdx
@@ -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 (
+
+
+
+
+
+ Left
+
+
+
+
+
+ Right
+
+
+
+
+
+ Left: {leftPct.toFixed(1)}% | Right: {rightPct.toFixed(1)}%
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ Top
+
+
+
+
+
+ Bottom
+
+
+
+
+
+ Top: {topPct.toFixed(1)}% | Bottom: {bottomPct.toFixed(1)}%
+
+
+ );
+}
+
+`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
+
+
+
+
+
+
+
+
+
+```
+
+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.
+
+
+
+
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+## Vertical Split
+
+Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one.
+
+
+
+
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+## Collapsing a Pane
+
+To collapse the fill pane, omit the `` and second ``. The sized pane's DOM identity is preserved across collapses, so its content state is not lost.
+
+```tsx
+
+
+
+
+ {!isCollapsed && (
+ <>
+
+
+
+
+ >
+ )}
+
+```
+
+## 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 (
+
+
+
+
+
+
+
+
+
+ );
+}
+```
+
+## Custom Divider
+
+For most cases, use `` — 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 (
+
+
+
+ );
+}
+
+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 `` inside the `` 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
+ trackStart(sizePct)}
+ onResize={newSize => trackEnd(newSize)}
+>
+
+
+
+
+
+
+
+
+```
+
+## 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 (
+
+
+
+
+
+ );
+}
+```
+
+## 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`.
diff --git a/static/app/components/core/splitPanel/splitPanel.spec.tsx b/static/app/components/core/splitPanel/splitPanel.spec.tsx
new file mode 100644
index 00000000000000..5c91af8b600ea3
--- /dev/null
+++ b/static/app/components/core/splitPanel/splitPanel.spec.tsx
@@ -0,0 +1,134 @@
+import {render, screen} from 'sentry-test/reactTestingLibrary';
+
+import {SplitPanel} from '@sentry/scraps/splitPanel';
+
+describe('SplitPanel', () => {
+ describe('horizontal orientation', () => {
+ it('renders both panels and a divider', () => {
+ render(
+
+
+
left
+
+
+
+
right
+
+
+ );
+
+ expect(screen.getByTestId('left-content')).toBeInTheDocument();
+ expect(screen.getByTestId('right-content')).toBeInTheDocument();
+ expect(screen.getByRole('separator')).toBeInTheDocument();
+ });
+
+ it('renders only the first panel when the second is omitted', () => {
+ render(
+
+
+
left
+
+
+ );
+
+ expect(screen.getByTestId('left-content')).toBeInTheDocument();
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument();
+ });
+
+ it('preserves DOM identity of the sized panel when toggling the fill panel', () => {
+ const {rerender} = render(
+
+
+
+
+
+ );
+
+ expect(screen.getByTestId('top-content')).toBeInTheDocument();
+ expect(screen.getByTestId('bottom-content')).toBeInTheDocument();
+ expect(screen.getByRole('separator')).toBeInTheDocument();
+ });
+
+ it('renders only the first panel when the second is omitted', () => {
+ render(
+
+
+
top
+
+
+ );
+
+ expect(screen.getByTestId('top-content')).toBeInTheDocument();
+ expect(screen.queryByRole('separator')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('divider accessibility', () => {
+ it('exposes separator role with orientation and value attributes', () => {
+ render(
+
+
+
left
+
+
+
+
right
+
+
+ );
+
+ const separator = screen.getByRole('separator');
+ // Horizontal split → vertical divider line
+ expect(separator).toHaveAttribute('aria-orientation', 'vertical');
+ expect(separator).toHaveAttribute('aria-valuemin', '100');
+ expect(separator).toHaveAttribute('aria-valuemax', '600');
+ expect(separator).toHaveAttribute('tabindex', '0');
+ });
+ });
+});
diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx
new file mode 100644
index 00000000000000..d036026e953dd7
--- /dev/null
+++ b/static/app/components/core/splitPanel/splitPanel.tsx
@@ -0,0 +1,434 @@
+import {
+ Children,
+ createContext,
+ isValidElement,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+} from 'react';
+import styled from '@emotion/styled';
+
+import {useDimensions} from 'sentry/utils/useDimensions';
+import {useResizableDrawer} from 'sentry/utils/useResizableDrawer';
+
+type Orientation = 'horizontal' | 'vertical';
+
+type SplitPanelContextValue = {
+ isHeld: boolean;
+ isMaximized: boolean;
+ isMinimized: boolean;
+ isReady: boolean;
+ max: number;
+ maximiseSize: () => void;
+ min: number;
+ minimiseSize: () => void;
+ onDoubleClick: React.MouseEventHandler;
+ onKeyDown: React.KeyboardEventHandler;
+ onMouseDown: React.MouseEventHandler;
+ orientation: Orientation;
+ resetSize: () => void;
+ size: number;
+};
+
+const SplitPanelContext = createContext(null);
+
+function useSplitPanelContext(component: string): SplitPanelContextValue {
+ const ctx = useContext(SplitPanelContext);
+ if (!ctx) {
+ throw new Error(`${component} must be rendered inside `);
+ }
+ return ctx;
+}
+
+/**
+ * Imperative controls for the surrounding ``. Use from any
+ * descendant to programmatically maximise, minimise, or reset the sized pane.
+ */
+export function useSplitPanel() {
+ const {isMaximized, isMinimized, maximiseSize, minimiseSize, resetSize} =
+ useSplitPanelContext('useSplitPanel');
+ return {isMaximized, isMinimized, maximiseSize, minimiseSize, resetSize};
+}
+
+/**
+ * Returns everything needed to render a custom divider element. Spread
+ * `props` on the divider's root element to get ARIA, event handlers, and
+ * `tabIndex`. Use `isHeld` and `orientation` for styling.
+ */
+export function useSplitPanelDivider() {
+ const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} =
+ useSplitPanelContext('useSplitPanelDivider');
+ return {
+ isHeld,
+ orientation,
+ props: {
+ 'aria-orientation':
+ orientation === 'horizontal' ? ('vertical' as const) : ('horizontal' as const),
+ 'aria-valuemax': Number.isFinite(max) ? max : undefined,
+ 'aria-valuemin': min,
+ 'aria-valuenow': size,
+ onDoubleClick,
+ onKeyDown,
+ onMouseDown,
+ role: 'separator' as const,
+ tabIndex: 0,
+ },
+ };
+}
+
+export type SplitPanelProps = {
+ /**
+ * Exactly one `` followed by an optional
+ * `` and a second ``. When only one
+ * `` is rendered, it fills the container.
+ */
+ children: React.ReactNode;
+ /**
+ * Fires when the user starts dragging the divider. Receives the current
+ * size of the sized pane as a percentage of the container.
+ */
+ onMouseDown?: (sizePct: `${number}%`) => void;
+ /**
+ * Fires as the user drags. Receives the new size in pixels. Wire this to
+ * your own persistence layer if you want to remember the size across
+ * reloads (e.g. `useLocalStorageState`).
+ */
+ onResize?: (newSize: number) => void;
+ /**
+ * Layout direction. `horizontal` splits left/right; `vertical` splits
+ * top/bottom.
+ */
+ orientation?: Orientation;
+};
+
+export type SplitPanelPanelProps = {
+ children: React.ReactNode;
+ /**
+ * Initial size of this pane in pixels. The pane that declares `defaultSize`
+ * is the sized pane; the other fills the remaining space. Only one pane may
+ * be sized.
+ */
+ defaultSize?: number;
+ /**
+ * Maximum size in pixels the user may drag this pane to. Only meaningful on
+ * the sized pane.
+ */
+ maxSize?: number;
+ /**
+ * Minimum size in pixels the user may drag this pane to. Only meaningful on
+ * the sized pane.
+ */
+ minSize?: number;
+};
+
+export type SplitPanelDividerProps = Record;
+
+function Panel({children}: SplitPanelPanelProps) {
+ const {isReady, orientation, size} = useSplitPanelContext('SplitPanel.Panel');
+ const isSized = useIsSizedPanel();
+
+ return (
+
+ {children}
+
+ );
+}
+
+const IsSizedPanelContext = createContext(false);
+function useIsSizedPanel() {
+ return useContext(IsSizedPanelContext);
+}
+
+function Divider() {
+ const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} =
+ useSplitPanelContext('SplitPanel.Divider');
+
+ return (
+
+ );
+}
+
+function findSizedPanelProps(children: React.ReactNode): SplitPanelPanelProps | null {
+ let result: SplitPanelPanelProps | null = null;
+ Children.forEach(children, child => {
+ if (result) {
+ return;
+ }
+ if (isValidElement(child) && child.type === Panel) {
+ const props = child.props as SplitPanelPanelProps;
+ if (props.defaultSize !== undefined) {
+ result = props;
+ }
+ }
+ });
+ return result;
+}
+
+export function SplitPanel({
+ children,
+ orientation = 'horizontal',
+ onMouseDown,
+ onResize,
+}: SplitPanelProps) {
+ const containerRef = useRef(null);
+ const dims = useDimensions({elementRef: containerRef});
+ const availableSize = orientation === 'horizontal' ? dims.width : dims.height;
+
+ const sizedProps = useMemo(() => findSizedPanelProps(children), [children]);
+ const min = sizedProps?.minSize ?? 0;
+ const explicitMax = sizedProps?.maxSize ?? Number.POSITIVE_INFINITY;
+ // Cap the sized pane at the container size so it can never overflow the
+ // parent. Matches Zag/Chakra's `maxSize = 100%` default. Until we've
+ // measured, fall back to the explicit max (or Infinity) so the hook can
+ // accept the initial size without clamping it to zero.
+ const max = availableSize > 0 ? Math.min(explicitMax, availableSize) : explicitMax;
+ const initialSize = sizedProps?.defaultSize ?? 0;
+
+ // useResizableDrawer only enforces `min`, not `max`, so its internal size can
+ // drift past `max` while the user keeps dragging. We clamp before forwarding
+ // to the consumer's `onResize` and snap the hook back when it drifts, so
+ // dragging in the opposite direction responds immediately.
+ const setSizeRef = useRef<(size: number, userEvent?: boolean) => void>(() => {});
+ const handleHookResize = useCallback(
+ (newSize: number) => {
+ const clamped = Math.min(Math.max(newSize, min), max);
+ if (clamped !== newSize) {
+ setSizeRef.current(clamped, true);
+ return;
+ }
+ onResize?.(newSize);
+ },
+ [onResize, min, max]
+ );
+
+ const {
+ isHeld,
+ onDoubleClick,
+ onMouseDown: onDragStart,
+ setSize,
+ size: containerSize,
+ } = useResizableDrawer({
+ direction: orientation === 'horizontal' ? 'left' : 'down',
+ initialSize,
+ min,
+ onResize: handleHookResize,
+ });
+ setSizeRef.current = setSize;
+
+ const clampedSize = Math.min(containerSize, max);
+ const sizePct = `${
+ availableSize > 0 ? (clampedSize / availableSize) * 100 : 0
+ }%` as const;
+
+ const handleMouseDown = useCallback(
+ (event: React.MouseEvent) => {
+ onMouseDown?.(sizePct);
+ onDragStart(event);
+ },
+ [onDragStart, sizePct, onMouseDown]
+ );
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const step = event.shiftKey ? 50 : 10;
+ const isHorizontal = orientation === 'horizontal';
+ const decreaseKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';
+ const increaseKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';
+
+ if (event.key === decreaseKey) {
+ event.preventDefault();
+ setSize(Math.max(min, containerSize - step), true);
+ } else if (event.key === increaseKey) {
+ event.preventDefault();
+ setSize(Math.min(max, containerSize + step), true);
+ } else if (event.key === 'Home') {
+ event.preventDefault();
+ setSize(min, true);
+ } else if (event.key === 'End' && Number.isFinite(max)) {
+ event.preventDefault();
+ setSize(max, true);
+ }
+ },
+ [orientation, containerSize, min, max, setSize]
+ );
+
+ const isReady = availableSize > 0;
+ const isMaximized = containerSize >= max;
+ const isMinimized = containerSize <= min;
+
+ const contextValue = useMemo(
+ () => ({
+ isHeld,
+ isMaximized,
+ isMinimized,
+ isReady,
+ max,
+ maximiseSize: () => setSize(max),
+ min,
+ minimiseSize: () => setSize(min),
+ onDoubleClick,
+ onKeyDown: handleKeyDown,
+ onMouseDown: handleMouseDown,
+ orientation,
+ resetSize: () => setSize(initialSize),
+ size: isReady ? clampedSize : 0,
+ }),
+ [
+ clampedSize,
+ handleKeyDown,
+ handleMouseDown,
+ initialSize,
+ isHeld,
+ isMaximized,
+ isMinimized,
+ isReady,
+ max,
+ min,
+ onDoubleClick,
+ orientation,
+ setSize,
+ ]
+ );
+
+ // Tag each Panel child with whether it's the sized one. We can't read the
+ // tag inside Panel without an extra Context, so this drives IsSizedPanelContext.
+ let sizedPanelMarked = false;
+ const wrappedChildren = Children.map(children, child => {
+ if (isValidElement(child) && child.type === Panel) {
+ const childProps = child.props as SplitPanelPanelProps;
+ const isThisPanelSized = !sizedPanelMarked && childProps.defaultSize !== undefined;
+ if (isThisPanelSized) {
+ sizedPanelMarked = true;
+ }
+ return (
+
+ {child}
+
+ );
+ }
+ return child;
+ });
+
+ return (
+
+
+ {wrappedChildren}
+
+
+ );
+}
+
+SplitPanel.Panel = Panel;
+SplitPanel.Divider = Divider;
+
+const SplitPanelRoot = styled('div')`
+ position: relative;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ flex-grow: 1;
+
+ &[data-orientation='horizontal'] {
+ flex-direction: row;
+ }
+ &[data-orientation='vertical'] {
+ flex-direction: column;
+ }
+
+ /*
+ * Disable iframe pointer events while dragging so the divider doesn't lose
+ * the cursor when crossing an embedded iframe (e.g. the Replay player).
+ */
+ &&.disable-iframe-pointer iframe {
+ pointer-events: none !important;
+ }
+`;
+
+const PanelContainer = styled('div')<{sizePx: number | undefined}>`
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ ${p => (p.sizePx === undefined ? 'flex: 1 1 0;' : `flex: 0 0 ${p.sizePx}px;`)}
+`;
+
+const DividerLine = styled('div')`
+ position: relative;
+ flex-shrink: 0;
+ user-select: none;
+
+ /* Invisible wider hit area for dragging */
+ &::before {
+ content: '';
+ position: absolute;
+ z-index: 1;
+ }
+
+ &[data-orientation='horizontal'] {
+ width: 0;
+ height: 100%;
+ cursor: ew-resize;
+ border-left: 1px solid ${p => p.theme.tokens.border.primary};
+
+ &::before {
+ top: 0;
+ bottom: 0;
+ left: -5px;
+ width: 11px;
+ }
+
+ &:hover,
+ &[data-is-held='true'] {
+ border-left-color: ${p => p.theme.tokens.border.accent.moderate};
+ }
+ }
+
+ &[data-orientation='vertical'] {
+ width: 100%;
+ height: 0;
+ cursor: ns-resize;
+ border-top: 1px solid ${p => p.theme.tokens.border.primary};
+
+ &::before {
+ left: 0;
+ right: 0;
+ top: -5px;
+ height: 11px;
+ }
+
+ &:hover,
+ &[data-is-held='true'] {
+ border-top-color: ${p => p.theme.tokens.border.accent.moderate};
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${p => p.theme.tokens.focus.default};
+ }
+`;
diff --git a/static/app/components/splitPanel.spec.tsx b/static/app/components/splitPanel.spec.tsx
deleted file mode 100644
index b3ad5b40b6faa3..00000000000000
--- a/static/app/components/splitPanel.spec.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-import {render, screen} from 'sentry-test/reactTestingLibrary';
-
-import {SplitPanel} from 'sentry/components/splitPanel';
-
-const defaultLeftSide = {
- content: