Repository: Thinkmill/keystatic
Pull request #477 (2023-07-24 04:14:05)

Split view
Export `SplitView` components from new "split-view" package.

> A split view is two sections or "panes", including a moveable separator between them that allows users to change the relative size of the panes.

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 31558c0717800211db150fc67f71062acb2172d2

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Thinkmill/keystatic/new/split-view?filename=.changeset/tall-bees-sleep.md&value=---%0A%22%40keystar%2Fui%22%3A%20patch%0A%22keystatic-docs%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0ASplit%20view%0A)


vercel[bot]: [vc]: #dz2Lh7XsUmpbCXMugmysLpiVAd+g3lf7fX/2lVHwX6E=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5IjoiZGV2LXByb2plY3RzL25leHQtYXBwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC1sYWJzL2tleXN0YXRpYy85VXdWeWg4RkZpcnp3S2c3c2dNaTdMNXZLa1QyIiwicHJldmlld1VybCI6ImtleXN0YXRpYy1naXQtc3BsaXQtdmlldy10aGlua21pbGwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoia2V5c3Rhci11aSIsInJvb3REaXJlY3RvcnkiOiJkZXNpZ24tc3lzdGVtL2RvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMva2V5c3Rhci11aS8yb2ozVnk5eFFmRndkODdIY2FadndoUHRwRFpLIiwicHJldmlld1VybCI6ImtleXN0YXItdWktZ2l0LXNwbGl0LXZpZXctdGhpbmttaWxsLWxhYnMudmVyY2VsLmFwcCIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6ImtleXN0YXRpYy1zaXRlIiwicm9vdERpcmVjdG9yeSI6ImRvY3MiLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vdGhpbmttaWxsLWxhYnMva2V5c3RhdGljLXNpdGUvMlZzRDMyWkNhNjJQV1RoVm1ucHd2azNzTnQ4ZyIsInByZXZpZXdVcmwiOiJrZXlzdGF0aWMtc2l0ZS1naXQtc3BsaXQtdmlldy10aGlua21pbGwtbGFicy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **keystar-ui** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystar-ui/2oj3Vy9xQfFwd87HcaZvwhPtpDZK)) | [Visit Preview](https://keystar-ui-git-split-view-thinkmill-labs.vercel.app) | Jul 24, 2023 4:14am |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic/9UwVyh8FFirzwKg7sgMi7L5vKkT2)) | [Visit Preview](https://keystatic-git-split-view-thinkmill-labs.vercel.app) | Jul 24, 2023 4:14am |
| **keystatic-site** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic-site/2VsD32ZCa62PWThVmnpwvk3sNt8g)) | [Visit Preview](https://keystatic-site-git-split-view-thinkmill-labs.vercel.app) | Jul 24, 2023 4:14am |





Patch:
diff --git a/.changeset/flat-hotels-taste.md b/.changeset/flat-hotels-taste.md
new file mode 100644
index 00000000..5d2dd229
--- /dev/null
+++ b/.changeset/flat-hotels-taste.md
@@ -0,0 +1,6 @@
+---
+'@keystatic/core': patch
+'@keystar/ui': patch
+---
+
+Export `SplitView` components from new "split-view" package.
diff --git a/design-system/pkg/package.json b/design-system/pkg/package.json
index e806cc41..9e16fbfc 100644
--- a/design-system/pkg/package.json
+++ b/design-system/pkg/package.json
@@ -188,6 +188,11 @@
       "module": "./list-view/dist/keystar-ui-list-view.esm.js",
       "default": "./list-view/dist/keystar-ui-list-view.cjs.js"
     },
+    "./split-view": {
+      "types": "./split-view/dist/keystar-ui-split-view.cjs.js",
+      "module": "./split-view/dist/keystar-ui-split-view.esm.js",
+      "default": "./split-view/dist/keystar-ui-split-view.cjs.js"
+    },
     "./text-field": {
       "types": "./text-field/dist/keystar-ui-text-field.cjs.js",
       "module": "./text-field/dist/keystar-ui-text-field.esm.js",
diff --git a/design-system/pkg/split-view/package.json b/design-system/pkg/split-view/package.json
new file mode 100644
index 00000000..b41dd612
--- /dev/null
+++ b/design-system/pkg/split-view/package.json
@@ -0,0 +1,4 @@
+{
+  "main": "dist/keystar-ui-split-view.cjs.js",
+  "module": "dist/keystar-ui-split-view.esm.js"
+}
diff --git a/design-system/pkg/src/split-view/SplitView.tsx b/design-system/pkg/src/split-view/SplitView.tsx
new file mode 100644
index 00000000..3f3ec513
--- /dev/null
+++ b/design-system/pkg/src/split-view/SplitView.tsx
@@ -0,0 +1,504 @@
+import {
+  classNames,
+  css,
+  tokenSchema,
+  transition,
+  useStyleProps,
+} from '@keystar/ui/style';
+import { useId } from '@keystar/ui/utils';
+import { useLocale } from '@react-aria/i18n';
+import { filterDOMProps, useUpdateEffect } from '@react-aria/utils';
+import { clamp } from '@react-stately/utils';
+import {
+  ForwardedRef,
+  ForwardRefExoticComponent,
+  HTMLAttributes,
+  Ref,
+  forwardRef,
+  useEffect,
+  useRef,
+  useState,
+} from 'react';
+
+import { SplitViewProvider, useSplitView } from './context';
+import { resetGlobalCursorStyle, setGlobalCursorStyle } from './cursor';
+import { defaultStorage } from './storage';
+import {
+  CursorState,
+  ResizeEvent,
+  SplitPanePrimaryProps,
+  SplitPaneSecondaryProps,
+  SplitViewProps,
+} from './types';
+import {
+  getPercentage,
+  getPosition,
+  getPrimaryPane,
+  getPrimaryPaneId,
+  getResizeHandle,
+  getResizeHandleId,
+  getSecondaryPane,
+  getSecondaryPaneId,
+  px,
+} from './utils';
+
+const MAX_WIDTH_PROP = '--primary-pane-max-width';
+const MAX_WIDTH_VAR = `var(${MAX_WIDTH_PROP})`;
+const MIN_WIDTH_PROP = '--primary-pane-min-width';
+const MIN_WIDTH_VAR = `var(${MIN_WIDTH_PROP})`;
+const WIDTH_PROP = '--primary-pane-width';
+const WIDTH_VAR = `var(${WIDTH_PROP})`;
+const SNAP_REGION_PX = 32;
+const KEYBOARD_ARROW_STEPS = 10;
+
+export function SplitView(props: SplitViewProps) {
+  let {
+    autoSaveId,
+    children,
+    defaultSize,
+    isCollapsed,
+    minSize,
+    maxSize,
+    onCollapseChange,
+    onResize,
+    storage = defaultStorage,
+  } = props;
+  const [startPane, endPane] = children;
+
+  const id = useId(props.id);
+  const { direction } = useLocale();
+  const styleProps = useStyleProps(props);
+
+  const [isReversed, setReversed] = useState(false);
+  const [isDragging, setDragging] = useState(false);
+  // const [collapseRequested, setCollapseRequested] = useState(false);
+  const [handleIsFocused, setHandleFocus] = useState(false);
+  const [size, setSize] = useState(() => {
+    let size = defaultSize;
+    if (autoSaveId) {
+      let savedSize = storage.getItem(autoSaveId);
+      if (savedSize) {
+        size = Number.parseInt(savedSize);
+      }
+    }
+    return size;
+  });
+
+  const wrapperRef = useRef<HTMLDivElement>(null);
+  const offsetRef = useRef(0);
+  const moveRef = useRef(0);
+
+  // reverse drag logic when the primary pane is on the right or if the locale
+  // direction is right-to-left
+  useEffect(() => {
+    const resizeHandle = getResizeHandle(id);
+    const primaryPane = getPrimaryPane(id);
+    const secondaryPane = getSecondaryPane(id);
+    setReversed(
+      direction === 'rtl'
+        ? resizeHandle?.previousElementSibling === primaryPane
+        : resizeHandle?.previousElementSibling === secondaryPane
+    );
+  }, [direction, id]);
+
+  // sync size with subscribers
+  useUpdateEffect(() => onResize?.(size), [size]);
+  useEffect(() => {
+    wrapperRef.current?.style.setProperty(WIDTH_PROP, px(size));
+    moveRef.current = size;
+
+    if (autoSaveId) {
+      storage.setItem(autoSaveId, px(size));
+    }
+  }, [autoSaveId, onResize, size, storage]);
+
+  useEffect(() => {
+    const wrapper = wrapperRef.current;
+    const resizeHandle = getResizeHandle(id);
+    const primaryPane = getPrimaryPane(id);
+
+    if (!wrapper || !resizeHandle || !primaryPane) {
+      return;
+    }
+
+    let collapseRequested = false;
+    let collapseAllowed = typeof isCollapsed === 'boolean';
+
+    const onMove = (e: ResizeEvent) => {
+      e.preventDefault();
+
+      let delta = getPosition(e) - offsetRef.current;
+      if (isReversed) delta = delta * -1;
+      let nextWidth = size + delta;
+
+      // snap to the default width when the user drags near it
+      if (Math.abs(nextWidth - defaultSize) < SNAP_REGION_PX / 2) {
+        nextWidth = defaultSize;
+      }
+
+      // soft collapse the primary pane when smaller than half of its min-size.
+      // collapse state is committed when the drag handle is released.
+      if (collapseAllowed) {
+        collapseRequested = nextWidth <= minSize / 2;
+      }
+      if (collapseRequested) {
+        primaryPane.style.setProperty('width', '0px');
+        moveRef.current = size;
+      } else {
+        moveRef.current = nextWidth;
+        primaryPane.style.removeProperty('width');
+      }
+
+      wrapper.style.setProperty(WIDTH_PROP, px(moveRef.current));
+
+      let cursorStyle: CursorState = 'horizontal';
+      if (moveRef.current < minSize) {
+        cursorStyle = 'horizontal-min';
+      }
+      if (moveRef.current > maxSize) {
+        cursorStyle = 'horizontal-max';
+      }
+      setGlobalCursorStyle(cursorStyle, isReversed);
+    };
+
+    const stopDragging = () => {
+      resizeHandle.blur();
+      setDragging(false);
+      resetGlobalCursorStyle();
+
+      if (collapseRequested) {
+        onCollapseChange?.(!isCollapsed);
+        primaryPane.style.removeProperty('width');
+      } else {
+        setSize(clamp(moveRef.current, minSize, maxSize));
+      }
+
+      collapseRequested = false;
+
+      document.body.removeEventListener('mousemove', onMove);
+      document.body.removeEventListener('touchmove', onMove);
+      window.removeEventListener('mouseup', stopDragging);
+      window.removeEventListener('touchend', stopDragging);
+    };
+
+    const startDragging = (e: ResizeEvent) => {
+      if ('button' in e && e.button !== 0) {
+        return;
+      }
+      if ('touches' in e && e.touches.length !== 1) {
+        return;
+      }
+
+      setDragging(true);
+      offsetRef.current = getPosition(e);
+
+      document.body.addEventListener('mousemove', onMove);
+      document.body.addEventListener('touchmove', onMove);
+      window.addEventListener('mouseup', stopDragging);
+      window.addEventListener('touchend', stopDragging);
+    };
+
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.defaultPrevented) {
+        return;
+      }
+
+      // allow 10 steps between the min and max
+      let step = Math.round((maxSize - minSize) / KEYBOARD_ARROW_STEPS);
+      let increment = () => setSize(size => Math.min(size + step, maxSize));
+      let decrement = () => setSize(size => Math.max(size - step, minSize));
+
+      switch (e.key) {
+        case 'Enter':
+          if (collapseAllowed) {
+            e.preventDefault();
+            onCollapseChange?.(!isCollapsed);
+          }
+          break;
+        case 'Home':
+          e.preventDefault();
+          setSize(minSize);
+          break;
+        case 'End':
+          e.preventDefault();
+          setSize(maxSize);
+          break;
+        case 'ArrowLeft':
+          e.preventDefault();
+          if (isReversed) {
+            increment();
+          } else {
+            decrement();
+          }
+          break;
+        case 'ArrowRight':
+          e.preventDefault();
+          if (isReversed) {
+            decrement();
+          } else {
+            increment();
+          }
+      }
+    };
+
+    let options = { passive: true };
+    let onDoubleClick = (e: MouseEvent) => {
+      // reset to the default size when the drag handle is double-clicked. the
+      // guard is to prevent this from firing when the user keeps the mouse down
+      // after the second click and begins to drag, which yields some really
+      // weird behavior.
+      if (e.clientX === offsetRef.current) {
+        setSize(defaultSize);
+      }
+    };
+
+    resizeHandle.addEventListener('contextmenu', stopDragging);
+    resizeHandle.addEventListener('dblclick', onDoubleClick);
+    resizeHandle.addEventListener('keydown', onKeyDown);
+    resizeHandle.addEventListener('mousedown', startDragging, options);
+    resizeHandle.addEventListener('touchstart', startDragging, options);
+
+    return () => {
+      resizeHandle.removeEventListener('contextmenu', stopDragging);
+      resizeHandle.removeEventListener('dblclick', onDoubleClick);
+      resizeHandle.removeEventListener('keydown', onKeyDown);
+      resizeHandle.removeEventListener('mousedown', startDragging);
+      resizeHandle.removeEventListener('touchstart', startDragging);
+    };
+  }, [
+    maxSize,
+    minSize,
+    defaultSize,
+    id,
+    isReversed,
+    size,
+    onCollapseChange,
+    isCollapsed,
+  ]);
+
+  return (
+    <SplitViewProvider
+      value={{
+        id,
+        isCollapsed,
+        activity: isDragging
+          ? 'pointer'
+          : handleIsFocused
+          ? 'keyboard'
+          : undefined,
+      }}
+    >
+      <div
+        {...styleProps}
+        {...filterDOMProps(props)}
+        ref={wrapperRef}
+        className={classNames(
+          css({
+            display: 'flex',
+            height: '100%',
+            width: '100%',
+            overflow: 'hidden',
+            [MIN_WIDTH_PROP]: px(minSize),
+            [MAX_WIDTH_PROP]: px(maxSize),
+            [WIDTH_PROP]: px(defaultSize),
+          }),
+          styleProps.className
+        )}
+      >
+        {startPane}
+        <SplitViewResizeHandle
+          aria-valuemin={0}
+          aria-valuemax={100}
+          aria-valuenow={getPercentage(size, minSize, maxSize)}
+          onBlur={() => setHandleFocus(false)}
+          onFocus={() => setHandleFocus(true)}
+        />
+        {endPane}
+      </div>
+    </SplitViewProvider>
+  );
+}
+
+// Styled components
+// -----------------------------------------------------------------------------
+
+type ResizeHandleProps = HTMLAttributes<HTMLDivElement>;
+
+export const SplitPanePrimary: ForwardRefExoticComponent<
+  SplitPanePrimaryProps & { ref?: Ref<HTMLDivElement> }
+> = forwardRef(function SplitPanePrimary(
+  props: SplitPanePrimaryProps,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  let { activity, id, isCollapsed } = useSplitView();
+  let styleProps = useStyleProps(props);
+
+  // it feels like `aria-expanded` should be on the primary pane (which is
+  // controlled by the "separator"), but it's actually not supported:
+  // https://github.com/w3c/aria-practices/issues/129
+  // https://www.w3.org/TR/wai-aria-1.1/#aria-expanded
+  return (
+    <div
+      {...styleProps}
+      {...filterDOMProps(props)}
+      ref={forwardedRef}
+      id={getPrimaryPaneId(id)}
+      data-split-pane="primary"
+      data-split-view-activity={activity}
+      data-split-view-collapsed={isCollapsed || undefined}
+      className={classNames(
+        css({
+          containerType: 'inline-size',
+          overflow: 'hidden',
+          width: `clamp(${MIN_WIDTH_VAR},${WIDTH_VAR},${MAX_WIDTH_VAR})`,
+          // prevent the secondary pane from collapsing completely, regardless of
+          // consumer preference. losing the drag handle is a bad experience.
+          maxWidth: `calc(100% - 100px)`,
+
+          // hide when collapsed
+          '&[data-split-view-collapsed]': {
+            visibility: 'hidden',
+            width: 0,
+          },
+          // support transition when not dragging
+          '&:not([data-split-view-activity])': {
+            transition: transition('width'),
+          },
+          // disable interactive elements during drag
+          '&[data-split-view-activity=pointer]': {
+            pointerEvents: 'none',
+          },
+        }),
+        styleProps.className
+      )}
+    >
+      {props.children}
+    </div>
+  );
+});
+
+export const SplitPaneSecondary: ForwardRefExoticComponent<
+  SplitPaneSecondaryProps & { ref?: Ref<HTMLDivElement> }
+> = forwardRef(function SplitPaneSecondary(
+  props: SplitPaneSecondaryProps,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  let { id, activity } = useSplitView();
+  let styleProps = useStyleProps(props);
+
+  return (
+    <div
+      {...styleProps}
+      {...filterDOMProps(props)}
+      ref={forwardedRef}
+      id={getSecondaryPaneId(id)}
+      data-split-pane="secondary"
+      data-split-view-activity={activity}
+      className={classNames(
+        css({
+          containerType: 'inline-size',
+          flex: `1 1 0`,
+          // prevent the secondary pane from collapsing completely, regardless of
+          // consumer preference. losing the drag handle is a bad experience.
+          minWidth: `100px`,
+          overflow: 'hidden',
+
+          // disable interactive elements during drag
+          '&[data-split-view-activity=pointer]': {
+            pointerEvents: 'none',
+          },
+        }),
+        styleProps.className
+      )}
+    >
+      {props.children}
+    </div>
+  );
+});
+
+const SplitViewResizeHandle = forwardRef(function SplitViewResizeHandle(
+  props: ResizeHandleProps,
+  forwardedRef: ForwardedRef<HTMLDivElement>
+) {
+  let { activity, id, isCollapsed } = useSplitView();
+
+  return (
+    <div
+      {...props}
+      ref={forwardedRef}
+      aria-controls={getPrimaryPaneId(id)}
+      aria-label="Resize" // FIXME: localize
+      aria-orientation="vertical"
+      id={getResizeHandleId(id)}
+      role="separator"
+      tabIndex={0}
+      data-split-view-resize-handle
+      data-split-view-activity={activity}
+      data-split-view-collapsed={isCollapsed || undefined}
+      className={css({
+        backgroundColor: tokenSchema.color.border.muted,
+        boxSizing: 'border-box',
+        cursor: 'ew-resize',
+        flexShrink: 0,
+        outline: 0,
+        position: 'relative',
+        touchAction: 'none',
+        transition: transition('background-color'),
+        userSelect: 'none',
+        width: tokenSchema.size.border.regular,
+        zIndex: 1,
+
+        // hide visually when collapsed. still allow keyboard focus
+        '&[data-split-view-collapsed]:not([data-split-view-activity])':
+          visuallyHiddenStyles,
+
+        // increase hit area
+        '&::before': {
+          content: '""',
+          position: 'absolute',
+          inset: `calc(${tokenSchema.size.space.small} * -1)`,
+        },
+
+        // drag indicator
+        '&::after': {
+          backgroundColor: tokenSchema.color.alias.backgroundHovered,
+          content: '""',
+          insetBlock: 0,
+          insetInline: `calc(${tokenSchema.size.border.medium} * -1)`,
+          opacity: 0,
+          position: 'absolute',
+          transition: transition('opacity'),
+        },
+        // delay transition to avoid unexpected flicker, the user may just be
+        // mousing between panes; this way we ensure intent
+        '&:hover': {
+          backgroundColor: tokenSchema.color.border.neutral,
+          transitionDelay: tokenSchema.animation.duration.regular,
+
+          '&::after': {
+            opacity: 1,
+            transitionDelay: tokenSchema.animation.duration.regular,
+          },
+        },
+        '&[data-split-view-activity]::after': {
+          backgroundColor: tokenSchema.color.background.accentEmphasis,
+          insetInline: `calc(${tokenSchema.size.border.regular} * -1)`,
+          opacity: 1,
+        },
+      })}
+    />
+  );
+});
+
+const visuallyHiddenStyles = {
+  border: 0,
+  clip: 'rect(0 0 0 0)',
+  clipPath: 'inset(50%)',
+  height: '1px',
+  margin: '-1px',
+  overflow: 'hidden',
+  padding: 0,
+  position: 'absolute',
+  width: '1px',
+  whiteSpace: 'nowrap',
+} as const;
diff --git a/design-system/pkg/src/split-view/context.tsx b/design-system/pkg/src/split-view/context.tsx
new file mode 100644
index 00000000..3447ed1e
--- /dev/null
+++ b/design-system/pkg/src/split-view/context.tsx
@@ -0,0 +1,22 @@
+import { createContext, useContext } from 'react';
+
+import { ResizeActivity } from './types';
+
+type ContextType = {
+  activity: ResizeActivity;
+  id: string;
+  isCollapsed: boolean | undefined;
+};
+const SplitViewContext = createContext<ContextType>({
+  activity: undefined,
+  id: '',
+  isCollapsed: undefined,
+});
+
+/** @private */
+export const SplitViewProvider = SplitViewContext.Provider;
+
+/** @private */
+export function useSplitView() {
+  return useContext(SplitViewContext);
+}
diff --git a/design-system/pkg/src/split-view/cursor.ts b/design-system/pkg/src/split-view/cursor.ts
new file mode 100644
index 00000000..74987020
--- /dev/null
+++ b/design-system/pkg/src/split-view/cursor.ts
@@ -0,0 +1,40 @@
+import { CursorState } from './types';
+
+let currentState: CursorState | null = null;
+let element: HTMLStyleElement | null = null;
+
+export function getCursorStyle(state: CursorState, isReversed: boolean) {
+  switch (state) {
+    case 'horizontal':
+      return 'ew-resize';
+    case 'horizontal-max':
+      return isReversed ? 'e-resize' : 'w-resize';
+    case 'horizontal-min':
+      return isReversed ? 'w-resize' : 'e-resize';
+  }
+}
+export function resetGlobalCursorStyle() {
+  if (element !== null) {
+    document.head.removeChild(element);
+
+    currentState = null;
+    element = null;
+  }
+}
+export function setGlobalCursorStyle(state: CursorState, isReversed: boolean) {
+  if (currentState === state) {
+    return;
+  }
+
+  currentState = state;
+
+  const style = getCursorStyle(state, isReversed);
+
+  if (element === null) {
+    element = document.createElement('style');
+
+    document.head.appendChild(element);
+  }
+
+  element.innerHTML = `*{cursor: ${style}!important;}`;
+}
diff --git a/design-system/pkg/src/split-view/docs/index.md b/design-system/pkg/src/split-view/docs/index.md
new file mode 100644
index 00000000..2b364692
--- /dev/null
+++ b/design-system/pkg/src/split-view/docs/index.md
@@ -0,0 +1,24 @@
+---
+title: SplitView
+description:
+  A split view is two sections or "panes", including a moveable separator
+  between them that allows users to change the relative size of the panes.
+category: Layout
+---
+
+## Example
+
+Note that the term "primary" does not describe the importance or purpose of
+content inside the pane. In this context, primary indicates which pane the user
+may resize, while the secondary pane fills the remaining space.
+
+```jsx {% live=true %}
+<SplitView minSize={100} maxSize={400} defaultSize={200} height="element.large">
+  <SplitPanePrimary>
+    <Text>Primary</Text>
+  </SplitPanePrimary>
+  <SplitPaneSecondary paddingX="regular">
+    <Text>Secondary</Text>
+  </SplitPaneSecondary>
+</SplitView>
+```
diff --git a/design-system/pkg/src/split-view/index.ts b/design-system/pkg/src/split-view/index.ts
new file mode 100644
index 00000000..9751f729
--- /dev/null
+++ b/design-system/pkg/src/split-view/index.ts
@@ -0,0 +1,5 @@
+'use client';
+
+export { SplitView, SplitPanePrimary, SplitPaneSecondary } from './SplitView';
+
+export type { SplitViewProps } from './types';
diff --git a/design-system/pkg/src/split-view/storage.ts b/design-system/pkg/src/split-view/storage.ts
new file mode 100644
index 00000000..24758cea
--- /dev/null
+++ b/design-system/pkg/src/split-view/storage.ts
@@ -0,0 +1,38 @@
+// Credit: https://github.com/bvaughn/react-resizable-panels/blob/main/packages/react-resizable-panels/src/PanelGroup.ts
+import { SplitViewStorage } from './types';
+
+// SplitView might be rendering in a server-side environment where
+// `localStorage` is not available or on a browser with cookies/storage
+// disabled. In either case, this function avoids accessing `localStorage` until
+// needed, and avoids throwing user-visible errors.
+function initializeDefaultStorage(storageObject: SplitViewStorage) {
+  try {
+    if (typeof localStorage !== 'undefined') {
+      // Bypass this check for future calls
+      storageObject.getItem = (name: string) => {
+        return localStorage.getItem(name);
+      };
+      storageObject.setItem = (name: string, value: string) => {
+        localStorage.setItem(name, value);
+      };
+    } else {
+      throw new Error('localStorage not supported in this environment');
+    }
+  } catch (error) {
+    console.error(error);
+
+    storageObject.getItem = () => null;
+    storageObject.setItem = () => {};
+  }
+}
+
+export const defaultStorage: SplitViewStorage = {
+  getItem: (name: string) => {
+    initializeDefaultStorage(defaultStorage);
+    return defaultStorage.getItem(name);
+  },
+  setItem: (name: string, value: string) => {
+    initializeDefaultStorage(defaultStorage);
+    defaultStorage.setItem(name, value);
+  },
+};
diff --git a/design-system/pkg/src/split-view/stories/SplitView.stories.tsx b/design-system/pkg/src/split-view/stories/SplitView.stories.tsx
new file mode 100644
index 00000000..c7e6a6af
--- /dev/null
+++ b/design-system/pkg/src/split-view/stories/SplitView.stories.tsx
@@ -0,0 +1,115 @@
+import { ActionButton } from '@keystar/ui/button';
+import { Text } from '@keystar/ui/typography';
+import { action } from '@keystar/ui-storybook';
+import { chain } from '@react-aria/utils';
+import { useState } from 'react';
+
+import { SplitView, SplitPanePrimary, SplitPaneSecondary } from '../index';
+
+export default {
+  title: 'Components/SplitView',
+};
+
+export const Default = () => (
+  <SplitView defaultSize={200} minSize={100} maxSize={400} height="100vh">
+    <SplitPanePrimary>
+      <Text>Primary</Text>
+    </SplitPanePrimary>
+    <SplitPaneSecondary>
+      <Text>Secondary</Text>
+    </SplitPaneSecondary>
+  </SplitView>
+);
+Default.storyName = 'default';
+
+export const InverseOrder = () => (
+  <SplitView defaultSize={200} minSize={100} maxSize={400} height="100vh">
+    <SplitPaneSecondary>
+      <Text>Secondary</Text>
+    </SplitPaneSecondary>
+    <SplitPanePrimary>
+      <Text>Primary</Text>
+    </SplitPanePrimary>
+  </SplitView>
+);
+InverseOrder.storyName = 'secondary | primary';
+
+export const Nested = () => (
+  <SplitView defaultSize={200} minSize={100} maxSize={400} height="100vh">
+    <SplitPanePrimary>
+      <Text>A. Primary</Text>
+    </SplitPanePrimary>
+    <SplitPaneSecondary>
+      <SplitView defaultSize={200} minSize={100} maxSize={400} height="100vh">
+        <SplitPaneSecondary>
+          <Text>B. Secondary</Text>
+        </SplitPaneSecondary>
+        <SplitPanePrimary>
+          <Text>B. Primary</Text>
+        </SplitPanePrimary>
+      </SplitView>
+    </SplitPaneSecondary>
+  </SplitView>
+);
+Nested.storyName = 'nested';
+
+export const ResizeHandler = () => (
+  <SplitView
+    defaultSize={200}
+    minSize={100}
+    maxSize={400}
+    height="100vh"
+    onResize={action('onResize')}
+  >
+    <SplitPanePrimary>
+      <Text>Primary</Text>
+    </SplitPanePrimary>
+    <SplitPaneSecondary>
+      <Text>Secondary</Text>
+    </SplitPaneSecondary>
+  </SplitView>
+);
+ResizeHandler.storyName = 'resize handler';
+
+export const CollapseBehaviour = () => {
+  let [isCollapsed, setCollapsed] = useState(false);
+  return (
+    <SplitView
+      defaultSize={200}
+      minSize={100}
+      maxSize={400}
+      height="100vh"
+      onResize={action('onResize')}
+      onCollapseChange={chain(setCollapsed, action('onCollapseChange'))}
+      isCollapsed={isCollapsed}
+    >
+      <SplitPanePrimary>
+        <Text>Primary</Text>
+      </SplitPanePrimary>
+      <SplitPaneSecondary>
+        <ActionButton onPress={() => setCollapsed(b => !b)}>
+          {isCollapsed ? 'Expand' : 'Collapse'}
+        </ActionButton>
+      </SplitPaneSecondary>
+    </SplitView>
+  );
+};
+CollapseBehaviour.storyName = 'collapse handler';
+
+export const Storage = () => (
+  <SplitView
+    defaultSize={200}
+    minSize={100}
+    maxSize={400}
+    height="100vh"
+    autoSaveId="storybook"
+  >
+    <SplitPanePrimary>
+      <Text>Primary</Text>
+    </SplitPanePrimary>
+    <SplitPaneSecondary>
+      <Text>Secondary</Text>
+    </SplitPaneSecondary>
+  </SplitView>
+);
+Storage.storyName = 'local storage';
diff --git a/design-system/pkg/src/split-view/types.ts b/design-system/pkg/src/split-view/types.ts
new file mode 100644
index 00000000..fec6b1d7
--- /dev/null
+++ b/design-system/pkg/src/split-view/types.ts
@@ -0,0 +1,51 @@
+import { ReactElement, ReactNode } from 'react';
+import { BaseStyleProps } from '@keystar/ui/style';
+import { DOMProps } from '@react-types/shared';
+
+export type CursorState = 'horizontal' | 'horizontal-max' | 'horizontal-min';
+export type ResizeActivity = 'pointer' | 'keyboard' | undefined;
+export type ResizeEvent = KeyboardEvent | MouseEvent | TouchEvent;
+export type SplitViewStorage = {
+  getItem(name: string): string | null;
+  setItem(name: string, value: string): void;
+};
+
+export type SplitViewProps = {
+  /** Unique ID used to auto-save the pane arrangement. */
+  autoSaveId?: string;
+  /** The primary and secondary split panes. */
+  children: [ReactElement, ReactElement];
+  /** The default size of the primary pane, in pixels. */
+  defaultSize: number;
+  /** Control the collapsed state of the primary pane. */
+  isCollapsed?: boolean;
+  /** The minimum size of the primary pane, in pixels. */
+  minSize: number;
+  /** The minimum size of the primary pane, in pixels. */
+  maxSize: number;
+  /**
+   * Callback that is called when the user collapses or expands the primary
+   * pane. A _collapse_ event is triggered when the primary pane is dragged beyond
+   * half its minimum size, or the user presses the `Enter` key while the resize
+   * handle is focused.
+   *
+   * Because we visually hide the resize handle (unless focused) to complete the
+   * collapsed appearance, an _expand_ event will only occur when the user
+   * presses the `Enter` key on a collapsed resize handle—you MUST implement an
+   * alternative "expand" interaction for pointer users.
+   */
+  onCollapseChange?: (isCollapsed: boolean) => void;
+  /** Callback that is called when the user resizes the panes. */
+  onResize?: (value: number) => void;
+  /**
+   * Custom storage API.
+   * @default localStorage
+   */
+  storage?: SplitViewStorage;
+} & DOMProps &
+  BaseStyleProps;
+
+export type SplitPanePrimaryProps = { children: ReactNode } & DOMProps &
+  BaseStyleProps;
+export type SplitPaneSecondaryProps = { children: ReactNode } & DOMProps &
+  BaseStyleProps;
diff --git a/design-system/pkg/src/split-view/utils.ts b/design-system/pkg/src/split-view/utils.ts
new file mode 100644
index 00000000..c264f239
--- /dev/null
+++ b/design-system/pkg/src/split-view/utils.ts
@@ -0,0 +1,47 @@
+import { ResizeEvent } from './types';
+
+export function getPosition(e: ResizeEvent) {
+  if (isMouseEvent(e)) {
+    return e.clientX;
+  } else if (isTouchEvent(e)) {
+    return e.touches[0].clientX;
+  }
+  return 0;
+}
+
+export function getPercentage(value: number, min: number, max: number) {
+  return Math.round(((value - min) / (max - min)) * 100);
+}
+
+export function getPrimaryPaneId(id: string) {
+  return `primary-pane-${id}`;
+}
+export function getSecondaryPaneId(id: string) {
+  return `secondary-pane-${id}`;
+}
+export function getResizeHandleId(id: string) {
+  return `resize-handle-${id}`;
+}
+export function getPrimaryPane(id: string) {
+  return document.getElementById(getPrimaryPaneId(id));
+}
+export function getSecondaryPane(id: string) {
+  return document.getElementById(getSecondaryPaneId(id));
+}
+export function getResizeHandle(id: string) {
+  return document.getElementById(getResizeHandleId(id));
+}
+
+export function px(value: number) {
+  return `${value}px`;
+}
+
+export function isKeyDown(event: ResizeEvent): event is KeyboardEvent {
+  return event.type === 'keydown';
+}
+export function isMouseEvent(event: ResizeEvent): event is MouseEvent {
+  return event.type.startsWith('mouse');
+}
+export function isTouchEvent(event: ResizeEvent): event is TouchEvent {
+  return event.type.startsWith('touch');
+}
diff --git a/design-system/pkg/src/style/responsive.ts b/design-system/pkg/src/style/responsive.ts
index 9baf3d8a..dfa5c88e 100644
--- a/design-system/pkg/src/style/responsive.ts
+++ b/design-system/pkg/src/style/responsive.ts
@@ -14,9 +14,9 @@ import {
 
 export const breakpoints: Record<Breakpoint, number> = {
   mobile: 0,
-  tablet: 768,
-  desktop: 1280,
-  wide: 1768,
+  tablet: 740,
+  desktop: 992,
+  wide: 1200,
 };
 
 // external stuff for composing responsive styles
diff --git a/design-system/pkg/src/utils/index.ts b/design-system/pkg/src/utils/index.ts
index b253ad48..4efb7c9d 100644
--- a/design-system/pkg/src/utils/index.ts
+++ b/design-system/pkg/src/utils/index.ts
@@ -3,3 +3,5 @@ export { composeId } from './composeId';
 export { isReactText } from './guards';
 export { flattenChildren, flattenElements } from './flattenChildren';
 export { toDataAttributes } from './toDataAttributes';
+export { usePrevious } from './usePrevious';
+export { useId } from './useId';
diff --git a/design-system/pkg/src/utils/useId.ts b/design-system/pkg/src/utils/useId.ts
new file mode 100644
index 00000000..c42e4826
--- /dev/null
+++ b/design-system/pkg/src/utils/useId.ts
@@ -0,0 +1,9 @@
+import React from 'react';
+
+/**
+ * A thin wrapper around `React.useId()` that supports a consumer provided ID.
+ */
+export function useId(id?: string) {
+  let generatedId = React.useId();
+  return id || generatedId;
+}
diff --git a/design-system/pkg/src/utils/usePrevious.ts b/design-system/pkg/src/utils/usePrevious.ts
new file mode 100644
index 00000000..e8e04bcf
--- /dev/null
+++ b/design-system/pkg/src/utils/usePrevious.ts
@@ -0,0 +1,9 @@
+import { useEffect, useRef } from 'react';
+
+export function usePrevious<T>(value: T) {
+  const ref = useRef<T>();
+  useEffect(() => {
+    ref.current = value;
+  });
+  return ref.current;
+}
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index 3f8ecbc6..c99fe93f 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -204,7 +204,6 @@
     "prosemirror-state": "^1.4.2",
     "prosemirror-transform": "^1.7.1",
     "prosemirror-view": "^1.30.2",
-    "react-resizable-panels": "^0.0.53",
     "scroll-into-view-if-needed": "^3.0.3",
     "slate": "^0.91.4",
     "slate-history": "^0.86.0",
diff --git a/packages/keystatic/src/app/CollectionPage.tsx b/packages/keystatic/src/app/CollectionPage.tsx
index 1e633c61..827185db 100644
--- a/packages/keystatic/src/app/CollectionPage.tsx
+++ b/packages/keystatic/src/app/CollectionPage.tsx
@@ -25,9 +25,9 @@ import { Config } from '../config';
 import { sortByDescriptor } from './collection-sort';
 import l10nMessages from './l10n/index.json';
 import { useRouter } from './router';
-import { AppShellRoot, EmptyState } from './shell';
+import { EmptyState } from './shell/empty-state';
 import { useTree, TreeData } from './shell/data';
-import { AppShellHeader } from './shell/header';
+import { PageRoot, PageHeader } from './shell/page';
 import { getCollectionPath, getEntriesInCollectionWithTreeKey } from './utils';
 import { notFound } from './not-found';
 
@@ -47,7 +47,7 @@ export function CollectionPage(props: CollectionPageProps) {
   let debouncedSearchTerm = useDebouncedValue(searchTerm, 300);
 
   return (
-    <AppShellRoot containerWidth={containerWidth}>
+    <PageRoot containerWidth={containerWidth}>
       <CollectionPageHeader
         collectionLabel={collectionConfig.label}
         createHref={`${props.basePath}/collection/${encodeURIComponent(
@@ -57,7 +57,7 @@ export function CollectionPage(props: CollectionPageProps) {
         onSearchTermChange={setSearchTerm}
       />
       <CollectionPageContent searchTerm={debouncedSearchTerm} {...props} />
-    </AppShellRoot>
+    </PageRoot>
   );
 }
 
@@ -71,7 +71,7 @@ function CollectionPageHeader(props: {
   const stringFormatter = useLocalizedStringFormatter(l10nMessages);
 
   return (
-    <AppShellHeader>
+    <PageHeader>
       <Breadcrumbs flex minWidth={0}>
         <Item key="collection">{collectionLabel}</Item>
       </Breadcrumbs>
@@ -88,7 +88,7 @@ function CollectionPageHeader(props: {
       <Button marginStart="auto" prominence="high" href={createHref}>
         {stringFormatter.format('add')}
       </Button>
-    </AppShellHeader>
+    </PageHeader>
   );
 }
 
diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx
index 0a02ef85..d1d854b3 100644
--- a/packages/keystatic/src/app/ItemPage.tsx
+++ b/packages/keystatic/src/app/ItemPage.tsx
@@ -40,14 +40,13 @@ import { ForkRepoDialog } from './fork-repo';
 import l10nMessages from './l10n/index.json';
 import { getDataFileExtension, getSlugGlobForCollection } from './path-utils';
 import { useRouter } from './router';
-import { AppShellBody, AppShellRoot } from './shell';
+import { PageBody, PageHeader, PageRoot } from './shell/page';
 import {
   useBaseCommit,
   useTree,
   useRepositoryId,
   useBranchInfo,
 } from './shell/data';
-import { AppShellHeader } from './shell/header';
 import { TreeNode } from './trees';
 import { useDeleteItem, useUpsertItem } from './updating';
 import { useItemData } from './useItemData';
@@ -212,7 +211,6 @@ function ItemPage(props: ItemPageProps) {
         {deleteResult.kind === 'error' && (
           <Notice tone="critical">{deleteResult.error.message}</Notice>
         )}
-        {/* <AppShellBody isScrollable> */}
         <Box
           id={formID}
           height="100%"
@@ -233,7 +231,6 @@ function ItemPage(props: ItemPageProps) {
             slugField={props.slugInfo}
           />
         </Box>
-        {/* </AppShellBody> */}
         <DialogContainer
           // ideally this would be a popover on desktop but using a DialogTrigger wouldn't work since
           // this doesn't open on click but after doing a network request and it failing and manually wiring about a popover and modal would be a pain
@@ -561,9 +558,9 @@ function ItemPageWrapper(props: {
   if (combined.kind === 'error') {
     return (
       <ItemPageShell {...props}>
-        <AppShellBody>
+        <PageBody>
           <Notice tone="critical">{combined.error.message}</Notice>
-        </AppShellBody>
+        </PageBody>
       </ItemPageShell>
     );
   }
@@ -588,9 +585,9 @@ function ItemPageWrapper(props: {
   if (combined.data.item === 'not-found') {
     return (
       <ItemPageShell {...props}>
-        <AppShellBody>
+        <PageBody>
           <Notice tone="caution">Entry not found.</Notice>
-        </AppShellBody>
+        </PageBody>
       </ItemPageShell>
     );
   }
@@ -627,10 +624,8 @@ const ItemPageShell = (
   const collectionConfig = props.config.collections![props.collection]!;
 
   return (
-    <AppShellRoot
-      containerWidth={containerWidthForEntryLayout(collectionConfig)}
-    >
-      <AppShellHeader>
+    <PageRoot containerWidth={containerWidthForEntryLayout(collectionConfig)}>
+      <PageHeader>
         <Breadcrumbs
           flex
           minWidth={0}
@@ -648,10 +643,10 @@ const ItemPageShell = (
           <Item key="item">{props.itemSlug}</Item>
         </Breadcrumbs>
         {props.headerActions}
-      </AppShellHeader>
+      </PageHeader>
 
       {props.children}
-    </AppShellRoot>
+    </PageRoot>
   );
 };
 
diff --git a/packages/keystatic/src/app/SingletonPage.tsx b/packages/keystatic/src/app/SingletonPage.tsx
index a199526d..0c6ac351 100644
--- a/packages/keystatic/src/app/SingletonPage.tsx
+++ b/packages/keystatic/src/app/SingletonPage.tsx
@@ -18,9 +18,8 @@ import { useEventCallback } from '../form/fields/document/DocumentEditor/ui-util
 import { getSingletonFormat, getSingletonPath, isGitHubConfig } from './utils';
 
 import { CreateBranchDuringUpdateDialog } from './ItemPage';
-import { AppShellBody, AppShellRoot } from './shell';
+import { PageBody, PageHeader, PageRoot } from './shell/page';
 import { useBaseCommit, useTree } from './shell/data';
-import { AppShellHeader } from './shell/header';
 import { TreeNode } from './trees';
 import { mergeDataStates } from './useData';
 import { useHasChanged } from './useHasChanged';
@@ -118,10 +117,8 @@ function SingletonPage({
   const formID = 'singleton-form';
 
   return (
-    <AppShellRoot
-      containerWidth={containerWidthForEntryLayout(singletonConfig)}
-    >
-      <AppShellHeader>
+    <PageRoot containerWidth={containerWidthForEntryLayout(singletonConfig)}>
+      <PageHeader>
         <Flex alignItems="center" gap="regular">
           <Heading elementType="h1" id="page-title" size="small">
             {singletonConfig.label}
@@ -165,7 +162,7 @@ function SingletonPage({
             {isCreating ? 'Create' : 'Save'}
           </Button>
         </ButtonGroup>
-      </AppShellHeader>
+      </PageHeader>
       <Flex
         elementType="form"
         id={formID}
@@ -229,7 +226,7 @@ function SingletonPage({
           )}
         </DialogContainer>
       </Flex>
-    </AppShellRoot>
+    </PageRoot>
   );
 }
 
@@ -237,11 +234,11 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) {
   const singletonConfig = props.config.singletons?.[props.singleton];
   if (!singletonConfig) notFound();
   const header = (
-    <AppShellHeader>
+    <PageHeader>
       <Heading elementType="h1" id="page-title" size="small">
         {singletonConfig.label}
       </Heading>
-    </AppShellHeader>
+    </PageHeader>
   );
   const format = useMemo(
     () => getSingletonFormat(props.config, props.singleton),
@@ -261,22 +258,22 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) {
   );
   if (combined.kind === 'error') {
     return (
-      <AppShellRoot>
+      <PageRoot>
         {header}
-        <AppShellBody>
+        <PageBody>
           <Notice margin="xxlarge" tone="critical">
             {combined.error.message}
           </Notice>
-        </AppShellBody>
-      </AppShellRoot>
+        </PageBody>
+      </PageRoot>
     );
   }
 
   if (combined.kind === 'loading') {
     return (
-      <AppShellRoot>
+      <PageRoot>
         {header}
-        <AppShellBody>
+        <PageBody>
           <Flex
             alignItems="center"
             justifyContent="center"
@@ -288,8 +285,8 @@ function SingletonPageWrapper(props: { singleton: string; config: Config }) {
               size="large"
             />
           </Flex>
-        </AppShellBody>
-      </AppShellRoot>
+        </PageBody>
+      </PageRoot>
     );
   }
 
diff --git a/packages/keystatic/src/app/create-item.tsx b/packages/keystatic/src/app/create-item.tsx
index 10fd144a..280c29e4 100644
--- a/packages/keystatic/src/app/create-item.tsx
+++ b/packages/keystatic/src/app/create-item.tsx
@@ -26,8 +26,7 @@ import {
 import { CreateBranchDuringUpdateDialog } from './ItemPage';
 import l10nMessages from './l10n/index.json';
 import { useRouter } from './router';
-import { AppShellRoot } from './shell';
-import { AppShellHeader } from './shell/header';
+import { PageRoot, PageHeader } from './shell/page';
 import { useBaseCommit, useTree } from './shell/data';
 import { TreeNode } from './trees';
 import { useSlugsInCollection } from './useSlugsInCollection';
@@ -127,10 +126,8 @@ export function CreateItem(props: {
 
   return (
     <>
-      <AppShellRoot
-        containerWidth={containerWidthForEntryLayout(collectionConfig)}
-      >
-        <AppShellHeader>
+      <PageRoot containerWidth={containerWidthForEntryLayout(collectionConfig)}>
+        <PageHeader>
           <Breadcrumbs
             flex
             minWidth={0}
@@ -159,7 +156,7 @@ export function CreateItem(props: {
           >
             {stringFormatter.format('create')}
           </Button>
-        </AppShellHeader>
+        </PageHeader>
         <Flex
           id={formID}
           elementType="form"
@@ -185,7 +182,7 @@ export function CreateItem(props: {
             slugField={slugInfo}
           />
         </Flex>
-      </AppShellRoot>
+      </PageRoot>
 
       <DialogContainer
         // ideally this would be a popover on desktop but using a DialogTrigger
diff --git a/packages/keystatic/src/app/dashboard/DashboardPage.tsx b/packages/keystatic/src/app/dashboard/DashboardPage.tsx
index 5965b20f..15e11d38 100644
--- a/packages/keystatic/src/app/dashboard/DashboardPage.tsx
+++ b/packages/keystatic/src/app/dashboard/DashboardPage.tsx
@@ -7,8 +7,7 @@ import { Heading } from '@keystar/ui/typography';
 
 import { Config } from '../../config';
 import l10nMessages from '../l10n/index.json';
-import { AppShellBody, AppShellRoot } from '../shell';
-import { AppShellHeader } from '../shell/header';
+import { PageBody, PageHeader, PageRoot } from '../shell/page';
 import { useViewer } from '../shell/viewer-data';
 import { BranchSection } from './BranchSection';
 import { CollectionSection } from './CollectionSection';
@@ -19,13 +18,13 @@ export function DashboardPage(props: { config: Config; basePath: string }) {
   const user = useViewer();
 
   return (
-    <AppShellRoot containerWidth="large">
-      <AppShellHeader>
+    <PageRoot containerWidth="large">
+      <PageHeader>
         <Breadcrumbs flex minWidth={0}>
           <Item key="dashboard">{stringFormatter.format('dashboard')}</Item>
         </Breadcrumbs>
-      </AppShellHeader>
-      <AppShellBody isScrollable>
+      </PageHeader>
+      <PageBody isScrollable>
         <Flex direction="column" gap="xxlarge">
           {user && (
             <Flex
@@ -48,7 +47,7 @@ export function DashboardPage(props: { config: Config; basePath: string }) {
           <CollectionSection basePath={props.basePath} config={props.config} />
           <SingletonSection basePath={props.basePath} config={props.config} />
         </Flex>
-      </AppShellBody>
-    </AppShellRoot>
+      </PageBody>
+    </PageRoot>
   );
 }
diff --git a/packages/keystatic/src/app/entry-form.tsx b/packages/keystatic/src/app/entry-form.tsx
index 60baee79..7374be58 100644
--- a/packages/keystatic/src/app/entry-form.tsx
+++ b/packages/keystatic/src/app/entry-form.tsx
@@ -1,4 +1,9 @@
 import { Box, Grid } from '@keystar/ui/layout';
+import {
+  SplitView,
+  SplitPanePrimary,
+  SplitPaneSecondary,
+} from '@keystar/ui/split-view';
 
 import { FormatInfo } from './path-utils';
 import { ReadonlyPropPath } from '../form/fields/document/DocumentEditor/component-blocks/utils';
@@ -20,9 +25,9 @@ import {
   Collection,
   Singleton,
 } from '..';
-import { ContentPanelLayout, useContentPanelQuery } from './shell/panels';
 import { ScrollView } from './shell/primitives';
-import { AppShellContainer } from './shell';
+import { PageContainer } from './shell/page';
+import { useContentPanelQuery } from './shell/context';
 
 const emptyArray: ReadonlyPropPath = [];
 
@@ -59,49 +64,59 @@ export function FormForEntry({
     return (
       <PathContextProvider value={emptyArray}>
         <SlugFieldProvider value={slugField}>
-          <ContentPanelLayout>
-            <ScrollView>
-              <Box
-                padding={{
-                  mobile: 'medium',
-                  tablet: 'xlarge',
-                  desktop: 'xxlarge',
-                }}
-                minHeight={0}
-                minWidth={0}
-                maxWidth="container.medium"
-                marginX="auto"
-              >
-                <AddToPathProvider part={contentField.key}>
-                  <InnerFormValueContentFromPreviewProps
-                    forceValidation={forceValidation}
-                    {...props.fields[contentField.key]}
-                  />
-                </AddToPathProvider>
-              </Box>
-            </ScrollView>
-            <ScrollView>
-              <Grid
-                gap="xlarge"
-                padding={{
-                  mobile: 'medium',
-                  tablet: 'xlarge',
-                  desktop: 'xxlarge',
-                }}
-              >
-                {Object.entries(props.fields).map(([key, propVal]) =>
-                  key === contentField.key ? null : (
-                    <AddToPathProvider key={key} part={key}>
-                      <InnerFormValueContentFromPreviewProps
-                        forceValidation={forceValidation}
-                        {...propVal}
-                      />
-                    </AddToPathProvider>
-                  )
-                )}
-              </Grid>
-            </ScrollView>
-          </ContentPanelLayout>
+          <SplitView
+            autoSaveId="keystatic-content-split-view"
+            defaultSize={320}
+            minSize={240}
+            maxSize={480}
+            flex
+          >
+            <SplitPaneSecondary>
+              <ScrollView>
+                <Box
+                  padding={{
+                    mobile: 'medium',
+                    tablet: 'xlarge',
+                    desktop: 'xxlarge',
+                  }}
+                  minHeight={0}
+                  minWidth={0}
+                  maxWidth="container.medium"
+                  marginX="auto"
+                >
+                  <AddToPathProvider part={contentField.key}>
+                    <InnerFormValueContentFromPreviewProps
+                      forceValidation={forceValidation}
+                      {...props.fields[contentField.key]}
+                    />
+                  </AddToPathProvider>
+                </Box>
+              </ScrollView>
+            </SplitPaneSecondary>
+            <SplitPanePrimary>
+              <ScrollView>
+                <Grid
+                  gap="xlarge"
+                  padding={{
+                    mobile: 'medium',
+                    tablet: 'xlarge',
+                    desktop: 'xxlarge',
+                  }}
+                >
+                  {Object.entries(props.fields).map(([key, propVal]) =>
+                    key === contentField.key ? null : (
+                      <AddToPathProvider key={key} part={key}>
+                        <InnerFormValueContentFromPreviewProps
+                          forceValidation={forceValidation}
+                          {...propVal}
+                        />
+                      </AddToPathProvider>
+                    )
+                  )}
+                </Grid>
+              </ScrollView>
+            </SplitPanePrimary>
+          </SplitView>
         </SlugFieldProvider>
       </PathContextProvider>
     );
@@ -109,7 +124,7 @@ export function FormForEntry({
 
   return (
     <ScrollView>
-      <AppShellContainer
+      <PageContainer
         paddingY={{ mobile: 'medium', tablet: 'xlarge', desktop: 'xxlarge' }}
       >
         <FormValueContentFromPreviewProps
@@ -118,7 +133,7 @@ export function FormForEntry({
           slugField={slugField}
           {...props}
         />
-      </AppShellContainer>
+      </PageContainer>
     </ScrollView>
   );
 }
diff --git a/packages/keystatic/src/app/shell/context.tsx b/packages/keystatic/src/app/shell/context.tsx
index 64cf9655..21846db0 100644
--- a/packages/keystatic/src/app/shell/context.tsx
+++ b/packages/keystatic/src/app/shell/context.tsx
@@ -1,6 +1,17 @@
-import { createContext, useContext } from 'react';
+import {
+  RefObject,
+  createContext,
+  useCallback,
+  useContext,
+  useState,
+} from 'react';
 
 import { Config } from '../../config';
+import { useResizeObserver } from '@react-aria/utils';
+import { breakpoints } from '@keystar/ui/style';
+
+// Config context
+// -----------------------------------------------------------------------------
 
 export const ConfigContext = createContext<Config | null>(null);
 
@@ -11,3 +22,60 @@ export function useConfig(): Config {
   }
   return config;
 }
+
+// Page context
+// -----------------------------------------------------------------------------
+
+type ContentSize = keyof typeof breakpoints;
+type AboveSize = Exclude<ContentSize, 'wide'>;
+type BelowSize = Exclude<ContentSize, 'mobile'>;
+type QueryOptions =
+  | { above: AboveSize; below: BelowSize }
+  | { above: AboveSize }
+  | { below: BelowSize };
+
+const ContentPanelContext = createContext<ContentSize>('mobile');
+export const ContentPanelProvider = ContentPanelContext.Provider;
+
+export function useContentPanelSize() {
+  return useContext(ContentPanelContext);
+}
+export function useContentPanelQuery(options: QueryOptions) {
+  const sizes = ['mobile', 'tablet', 'desktop', 'wide'];
+  const size = useContentPanelSize();
+
+  const startIndex = 'above' in options ? sizes.indexOf(options.above) + 1 : 0;
+  const endIndex =
+    'below' in options ? sizes.indexOf(options.below) - 1 : sizes.length - 1;
+  const range = sizes.slice(startIndex, endIndex + 1);
+
+  return range.includes(size);
+}
+
+/** @private only used to initialize context */
+export function useContentPanelState(ref: RefObject<HTMLElement>) {
+  let [contentSize, setContentSize] = useState<ContentSize>('mobile');
+
+  const onResize = useCallback(() => {
+    setContentSize(size => {
+      let contentPane = ref.current;
+      if (!contentPane) {
+        return size;
+      }
+      if (contentPane.offsetWidth >= breakpoints.wide) {
+        return 'wide';
+      }
+      if (contentPane.offsetWidth >= breakpoints.desktop) {
+        return 'desktop';
+      }
+      if (contentPane.offsetWidth >= breakpoints.tablet) {
+        return 'tablet';
+      }
+      return 'mobile';
+    });
+  }, [ref]);
+
+  useResizeObserver({ ref, onResize });
+
+  return contentSize;
+}
diff --git a/packages/keystatic/src/app/shell/empty-state.tsx b/packages/keystatic/src/app/shell/empty-state.tsx
new file mode 100644
index 00000000..537622e8
--- /dev/null
+++ b/packages/keystatic/src/app/shell/empty-state.tsx
@@ -0,0 +1,44 @@
+import { ReactElement, ReactNode } from 'react';
+
+import { Icon } from '@keystar/ui/icon';
+import { Flex } from '@keystar/ui/layout';
+import { Heading, Text } from '@keystar/ui/typography';
+
+type EmptyStateProps =
+  | { children: ReactNode }
+  | {
+      title?: ReactNode;
+      icon?: ReactElement;
+      message?: ReactNode;
+      actions?: ReactNode;
+    };
+
+export function EmptyState(props: EmptyStateProps) {
+  return (
+    <Flex
+      alignItems="center"
+      direction="column"
+      gap="large"
+      justifyContent="center"
+      minHeight="scale.3000"
+      paddingX={{ mobile: 'medium', tablet: 'xlarge', desktop: 'xxlarge' }}
+    >
+      {'children' in props ? (
+        props.children
+      ) : (
+        <>
+          {props.icon && (
+            <Icon src={props.icon} size="large" color="neutralEmphasis" />
+          )}
+          {props.title && (
+            <Heading align="center" size="medium">
+              {props.title}
+            </Heading>
+          )}
+          {props.message && <Text align="center">{props.message}</Text>}
+          {props.actions}
+        </>
+      )}
+    </Flex>
+  );
+}
diff --git a/packages/keystatic/src/app/shell/header.tsx b/packages/keystatic/src/app/shell/header.tsx
deleted file mode 100644
index 0c08b71c..00000000
--- a/packages/keystatic/src/app/shell/header.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { useLocale } from '@react-aria/i18n';
-import { PropsWithChildren, useRef } from 'react';
-
-import { ActionButton } from '@keystar/ui/button';
-import { Icon } from '@keystar/ui/icon';
-import { panelLeftOpenIcon } from '@keystar/ui/icon/icons/panelLeftOpenIcon';
-import { panelLeftCloseIcon } from '@keystar/ui/icon/icons/panelLeftCloseIcon';
-import { panelRightOpenIcon } from '@keystar/ui/icon/icons/panelRightOpenIcon';
-import { panelRightCloseIcon } from '@keystar/ui/icon/icons/panelRightCloseIcon';
-import { Box, Flex } from '@keystar/ui/layout';
-import { css, tokenSchema } from '@keystar/ui/style';
-
-import { useSidebar } from './sidebar';
-import { AppShellContainer } from '.';
-
-export const AppShellHeader = ({ children }: PropsWithChildren) => {
-  const sidebarState = useSidebar();
-  const menuButtonRef = useRef<HTMLButtonElement>(null);
-  const { direction } = useLocale();
-
-  let icon = sidebarState.isOpen ? panelLeftCloseIcon : panelLeftOpenIcon;
-  if (direction === 'rtl') {
-    icon = sidebarState.isOpen ? panelRightCloseIcon : panelRightOpenIcon;
-  }
-
-  return (
-    <Box
-      borderBottom="muted"
-      elementType="header"
-      height={{ mobile: 'element.large', tablet: 'scale.700' }}
-      flexShrink={0}
-    >
-      <AppShellContainer>
-        <Flex
-          alignItems="center"
-          gap={{ mobile: 'small', tablet: 'regular' }}
-          height={{ mobile: 'element.large', tablet: 'scale.700' }}
-        >
-          <ActionButton
-            prominence="low"
-            isHidden={sidebarState.isOpen ? { above: 'mobile' } : undefined}
-            onPress={sidebarState.toggle}
-            ref={menuButtonRef}
-            UNSAFE_className={css({
-              marginInlineStart: `calc(${tokenSchema.size.space.regular} * -1)`,
-            })}
-          >
-            <Icon src={icon} />
-          </ActionButton>
-          {children}
-        </Flex>
-      </AppShellContainer>
-    </Box>
-  );
-};
diff --git a/packages/keystatic/src/app/shell/index.tsx b/packages/keystatic/src/app/shell/index.tsx
index ada04dd7..028e99fd 100644
--- a/packages/keystatic/src/app/shell/index.tsx
+++ b/packages/keystatic/src/app/shell/index.tsx
@@ -1,22 +1,12 @@
-import {
-  createContext,
-  PropsWithChildren,
-  ReactElement,
-  ReactNode,
-  useContext,
-} from 'react';
+import { ReactNode } from 'react';
 
 import { alertCircleIcon } from '@keystar/ui/icon/icons/alertCircleIcon';
-import { Icon } from '@keystar/ui/icon';
-import { Box, BoxProps, Flex } from '@keystar/ui/layout';
-import { VoussoirTheme } from '@keystar/ui/style';
-import { Heading, Text } from '@keystar/ui/typography';
+import { Flex } from '@keystar/ui/layout';
 
 import { Config } from '../../config';
 
 import { isGitHubConfig, isLocalConfig } from '../utils';
 
-import { MAIN_PANEL_ID } from './constants';
 import { ConfigContext } from './context';
 import {
   GitHubAppShellProvider,
@@ -26,7 +16,7 @@ import {
 import { SidebarProvider } from './sidebar';
 import { TopBar } from './topbar';
 import { MainPanelLayout } from './panels';
-import { ScrollView } from './primitives';
+import { EmptyState } from './empty-state';
 
 export const AppShell = (props: {
   config: Config;
@@ -85,111 +75,3 @@ export const AppShell = (props: {
   }
   return null;
 };
-
-// Styled components
-// -----------------------------------------------------------------------------
-
-type EmptyStateProps =
-  | { children: ReactNode }
-  | {
-      title?: ReactNode;
-      icon?: ReactElement;
-      message?: ReactNode;
-      actions?: ReactNode;
-    };
-export function EmptyState(props: EmptyStateProps) {
-  return (
-    <Flex
-      alignItems="center"
-      direction="column"
-      gap="large"
-      justifyContent="center"
-      minHeight="scale.3000"
-      paddingX={{ mobile: 'medium', tablet: 'xlarge', desktop: 'xxlarge' }}
-    >
-      {'children' in props ? (
-        props.children
-      ) : (
-        <>
-          {props.icon && (
-            <Icon src={props.icon} size="large" color="neutralEmphasis" />
-          )}
-          {props.title && (
-            <Heading align="center" size="medium">
-              {props.title}
-            </Heading>
-          )}
-          {props.message && <Text align="center">{props.message}</Text>}
-          {props.actions}
-        </>
-      )}
-    </Flex>
-  );
-}
-
-// Composite components
-// -----------------------------------------------------------------------------
-
-export const AppShellBody = ({
-  children,
-  isScrollable,
-}: PropsWithChildren<{ isScrollable?: boolean }>) => {
-  return (
-    <ScrollView isDisabled={!isScrollable}>
-      <AppShellContainer
-        // padding on the container so descendants can use sticky positioning
-        // with simple relative offsets
-        paddingY="xlarge"
-      >
-        {children}
-      </AppShellContainer>
-    </ScrollView>
-  );
-};
-
-type AppShellContextValue = {
-  containerWidth: keyof VoussoirTheme['size']['container'] | 'none';
-};
-const AppShellContext = createContext<AppShellContextValue>({
-  containerWidth: 'medium',
-});
-export const AppShellRoot = ({
-  children,
-  containerWidth = 'medium',
-}: PropsWithChildren<Partial<AppShellContextValue>>) => {
-  return (
-    <AppShellContext.Provider value={{ containerWidth }}>
-      <Flex
-        elementType="main"
-        direction="column"
-        id={MAIN_PANEL_ID}
-        flex
-        height="100%"
-        // fix flexbox issues
-        minHeight={0}
-        minWidth={0}
-      >
-        {children}
-      </Flex>
-    </AppShellContext.Provider>
-  );
-};
-
-export const AppShellContainer = (props: BoxProps) => {
-  const { containerWidth } = useContext(AppShellContext);
-  const maxWidth =
-    containerWidth === 'none'
-      ? undefined
-      : (`container.${containerWidth}` as const);
-
-  return (
-    <Box
-      minHeight={0}
-      minWidth={0}
-      maxWidth={maxWidth}
-      // marginX="auto"
-      paddingX={{ mobile: 'medium', tablet: 'xlarge', desktop: 'xxlarge' }}
-      {...props}
-    />
-  );
-};
diff --git a/packages/keystatic/src/app/shell/page.tsx b/packages/keystatic/src/app/shell/page.tsx
new file mode 100644
index 00000000..9d322803
--- /dev/null
+++ b/packages/keystatic/src/app/shell/page.tsx
@@ -0,0 +1,130 @@
+import { useLocale } from '@react-aria/i18n';
+import { createContext, PropsWithChildren, useContext, useRef } from 'react';
+
+import { ActionButton } from '@keystar/ui/button';
+import { Icon } from '@keystar/ui/icon';
+import { panelLeftOpenIcon } from '@keystar/ui/icon/icons/panelLeftOpenIcon';
+import { panelLeftCloseIcon } from '@keystar/ui/icon/icons/panelLeftCloseIcon';
+import { panelRightOpenIcon } from '@keystar/ui/icon/icons/panelRightOpenIcon';
+import { panelRightCloseIcon } from '@keystar/ui/icon/icons/panelRightCloseIcon';
+import { Box, BoxProps, Flex } from '@keystar/ui/layout';
+import { css, tokenSchema, VoussoirTheme } from '@keystar/ui/style';
+
+import { MAIN_PANEL_ID } from './constants';
+import { ScrollView } from './primitives';
+import { useSidebar } from './sidebar';
+import { ContentPanelProvider, useContentPanelState } from './context';
+
+type PageContextValue = {
+  containerWidth: keyof VoussoirTheme['size']['container'] | 'none';
+};
+const PageContext = createContext<PageContextValue>({
+  containerWidth: 'medium',
+});
+
+export const PageRoot = ({
+  children,
+  containerWidth = 'medium',
+}: PropsWithChildren<Partial<PageContextValue>>) => {
+  let ref = useRef<HTMLDivElement>(null);
+  let context = useContentPanelState(ref);
+
+  return (
+    <ContentPanelProvider value={context}>
+      <PageContext.Provider value={{ containerWidth }}>
+        <Flex
+          ref={ref}
+          elementType="main"
+          direction="column"
+          id={MAIN_PANEL_ID}
+          flex
+          height="100%"
+          // fix flexbox issues
+          minHeight={0}
+          minWidth={0}
+        >
+          {children}
+        </Flex>
+      </PageContext.Provider>
+    </ContentPanelProvider>
+  );
+};
+
+export const PageHeader = ({ children }: PropsWithChildren) => {
+  const sidebarState = useSidebar();
+  const menuButtonRef = useRef<HTMLButtonElement>(null);
+  const { direction } = useLocale();
+
+  let icon = sidebarState.isOpen ? panelLeftCloseIcon : panelLeftOpenIcon;
+  if (direction === 'rtl') {
+    icon = sidebarState.isOpen ? panelRightCloseIcon : panelRightOpenIcon;
+  }
+
+  return (
+    <Box
+      borderBottom="muted"
+      elementType="header"
+      height={{ mobile: 'element.large', tablet: 'scale.700' }}
+      flexShrink={0}
+    >
+      <PageContainer>
+        <Flex
+          alignItems="center"
+          gap={{ mobile: 'small', tablet: 'regular' }}
+          height={{ mobile: 'element.large', tablet: 'scale.700' }}
+        >
+          <ActionButton
+            prominence="low"
+            aria-label="Open app navigation"
+            aria-pressed={sidebarState.isOpen}
+            isHidden={sidebarState.isOpen ? { above: 'tablet' } : undefined}
+            onPress={sidebarState.toggle}
+            ref={menuButtonRef}
+            UNSAFE_className={css({
+              marginInlineStart: `calc(${tokenSchema.size.space.regular} * -1)`,
+            })}
+          >
+            <Icon src={icon} />
+          </ActionButton>
+          {children}
+        </Flex>
+      </PageContainer>
+    </Box>
+  );
+};
+
+export const PageBody = ({
+  children,
+  isScrollable,
+}: PropsWithChildren<{ isScrollable?: boolean }>) => {
+  return (
+    <ScrollView isDisabled={!isScrollable}>
+      <PageContainer
+        // padding on the container so descendants can use sticky positioning
+        // with simple relative offsets
+        paddingY="xlarge"
+      >
+        {children}
+      </PageContainer>
+    </ScrollView>
+  );
+};
+
+export const PageContainer = (props: BoxProps) => {
+  const { containerWidth } = useContext(PageContext);
+  const maxWidth =
+    containerWidth === 'none'
+      ? undefined
+      : (`container.${containerWidth}` as const);
+
+  return (
+    <Box
+      minHeight={0}
+      minWidth={0}
+      maxWidth={maxWidth}
+      // marginX="auto"
+      paddingX={{ mobile: 'medium', tablet: 'xlarge', desktop: 'xxlarge' }}
+      {...props}
+    />
+  );
+};
diff --git a/packages/keystatic/src/app/shell/panels.tsx b/packages/keystatic/src/app/shell/panels.tsx
index 7241573e..3979c5b6 100644
--- a/packages/keystatic/src/app/shell/panels.tsx
+++ b/packages/keystatic/src/app/shell/panels.tsx
@@ -1,60 +1,15 @@
 import {
-  breakpointQueries,
-  breakpoints,
-  css,
-  tokenSchema,
-  transition,
-  useMediaQuery,
-} from '@keystar/ui/style';
-import { useLayoutEffect } from '@react-aria/utils';
-import {
-  ReactElement,
-  ReactNode,
-  createContext,
-  useContext,
-  useRef,
-  useState,
-} from 'react';
-import {
-  PanelGroup,
-  Panel,
-  PanelResizeHandle,
-  ImperativePanelHandle,
-  PanelResizeHandleProps,
-} from 'react-resizable-panels';
+  SplitView,
+  SplitPanePrimary,
+  SplitPaneSecondary,
+} from '@keystar/ui/split-view';
+import { breakpointQueries, useMediaQuery } from '@keystar/ui/style';
+import { ReactNode } from 'react';
 
 import { Config } from '../../config';
 
 import { SidebarDialog, SidebarPanel, useSidebar } from './sidebar';
 
-// Content context
-// ------------------------------
-
-type ContentSize = keyof typeof breakpoints;
-type AboveSize = Exclude<ContentSize, 'wide'>;
-type BelowSize = Exclude<ContentSize, 'mobile'>;
-type QueryOptions =
-  | { above: AboveSize; below: BelowSize }
-  | { above: AboveSize }
-  | { below: BelowSize };
-
-const ContentPanelContext = createContext<ContentSize>('mobile');
-
-export function useContentPanelSize() {
-  return useContext(ContentPanelContext);
-}
-export function useContentPanelQuery(options: QueryOptions) {
-  const sizes = ['mobile', 'tablet', 'desktop', 'wide'];
-  const size = useContentPanelSize();
-
-  const startIndex = 'above' in options ? sizes.indexOf(options.above) + 1 : 0;
-  const endIndex =
-    'below' in options ? sizes.indexOf(options.below) - 1 : sizes.length - 1;
-  const range = sizes.slice(startIndex, endIndex + 1);
-
-  return range.includes(size);
-}
-
 // Main panel layout
 // -----------------------------------------------------------------------------
 
@@ -65,287 +20,33 @@ export const MainPanelLayout = (props: {
 }) => {
   let { basePath, children, config } = props;
 
-  let isBelowTablet = useMediaQuery(breakpointQueries.below.tablet);
-  let [isDragging, setIsDragging] = useState(false);
-  let [size, setSize] = useState(() => getInitialSizes());
-  let [contentSize, setContentSize] = useState<ContentSize>('mobile');
+  let isBelowDesktop = useMediaQuery(breakpointQueries.below.desktop);
   let sidebarState = useSidebar();
-  let sidebarPanelRef = useRef<ImperativePanelHandle>(null);
-
-  const contentPanelId = 'content';
-  const panelGroupId = 'main';
-
-  // Sync sidebar context with panel state.
-  useLayoutEffect(() => {
-    let panel = sidebarPanelRef.current;
-    if (panel) {
-      if (!sidebarState.isOpen) {
-        panel.collapse();
-      } else {
-        panel.expand();
-      }
-    }
-  }, [sidebarState.isOpen]);
 
-  // Handle cases where the sidebar has an invalid size. This can happen when
-  // the panel has been resized in a larger window, then the window is resized
-  // to be smaller, or vice versa.
-  useLayoutEffect(() => {
-    let panel = sidebarPanelRef.current;
-    if (panel && !panel.getCollapsed()) {
-      let currentSize = panel.getSize();
-      if (currentSize < size.minSize || currentSize > size.maxSize) {
-        panel.resize(clamp(currentSize, size.minSize, size.maxSize));
-      }
-    }
-  }, [size.maxSize, size.minSize]);
-
-  useLayoutEffect(() => {
-    const panelGroup: HTMLElement | null = document.querySelector(
-      `[data-panel-group-id="${panelGroupId}"]`
-    );
-    const contentPanel: HTMLElement | null = document.querySelector(
-      `[data-panel-id="${contentPanelId}"]`
-    );
-    const resizeHandles: NodeListOf<HTMLElement> = document.querySelectorAll(
-      '[data-panel-resize-handle-id]'
+  // no split view on small devices
+  if (isBelowDesktop) {
+    return (
+      <>
+        <SidebarDialog hrefBase={basePath} config={config} />
+        {children}
+      </>
     );
-
-    if (!panelGroup || !contentPanel) {
-      return;
-    }
-
-    const observer = new ResizeObserver(() => {
-      // GROUP: pixel fixes
-      let groupWidth = panelGroup.offsetWidth;
-
-      resizeHandles.forEach(resizeHandle => {
-        // subtract the width of the resize handles
-        groupWidth -= resizeHandle.offsetWidth;
-      });
-
-      let minSize = calcMin(groupWidth);
-      let maxSize = calcMax(groupWidth);
-      let defaultSize = calcDefault(groupWidth);
-
-      setSize({ minSize, maxSize, defaultSize });
-
-      // CONTENT PANEL: for context
-      let contentWidth = contentPanel.offsetWidth;
-
-      setContentSize(() => {
-        if (contentWidth >= breakpoints.wide) {
-          return 'wide';
-        }
-        if (contentWidth >= breakpoints.desktop) {
-          return 'desktop';
-        }
-        if (contentWidth >= breakpoints.tablet) {
-          return 'tablet';
-        }
-        return 'mobile';
-      });
-    });
-    observer.observe(panelGroup);
-    observer.observe(contentPanel);
-    resizeHandles.forEach(resizeHandle => {
-      observer.observe(resizeHandle);
-    });
-
-    return () => {
-      observer.disconnect();
-    };
-  }, []);
-
-  return (
-    <ContentPanelContext.Provider value={contentSize}>
-      <PanelGroup
-        disablePointerEventsDuringResize
-        id={panelGroupId}
-        autoSaveId={panelGroupId}
-        direction="horizontal"
-        className={css({ flex: 1 })}
-      >
-        {isBelowTablet ? (
-          <SidebarDialog hrefBase={basePath} config={config} />
-        ) : (
-          <>
-            <Panel
-              order={1}
-              collapsible
-              defaultSize={size.defaultSize}
-              maxSize={size.maxSize}
-              minSize={size.minSize}
-              onCollapse={isCollapsed => sidebarState.setOpen(!isCollapsed)}
-              ref={sidebarPanelRef}
-              className={css({
-                containerName: 'app-side-panel',
-                containerType: 'inline-size',
-              })}
-            >
-              <SidebarPanel hrefBase={basePath} config={config} />
-            </Panel>
-            <ResizeHandle
-              onDragging={setIsDragging}
-              disabled={!isDragging && !sidebarState.isOpen}
-            />
-          </>
-        )}
-        <Panel
-          order={2}
-          id={contentPanelId}
-          className={css({
-            containerName: 'app-main-panel',
-            containerType: 'inline-size',
-          })}
-        >
-          {children}
-        </Panel>
-      </PanelGroup>
-    </ContentPanelContext.Provider>
-  );
-};
-
-// Content panel layout
-// -----------------------------------------------------------------------------
-
-export const ContentPanelLayout = ({
-  children,
-}: {
-  children: [ReactElement, ReactElement];
-}) => {
-  const [main, aside] = children;
-  const panelGroupId = 'content';
+  }
 
   return (
-    <PanelGroup
-      disablePointerEventsDuringResize
-      id={panelGroupId}
-      autoSaveId={panelGroupId}
-      direction="horizontal"
-      className={css({ flex: 1 })}
+    <SplitView
+      autoSaveId="keystatic-app-split-view"
+      isCollapsed={!sidebarState.isOpen}
+      onCollapseChange={sidebarState.toggle}
+      defaultSize={260}
+      minSize={180}
+      maxSize={400}
+      flex
     >
-      <Panel minSize={25} maxSize={75} defaultSize={67}>
-        {main}
-      </Panel>
-      <ResizeHandle />
-      <Panel minSize={25} maxSize={75} defaultSize={33}>
-        {aside}
-      </Panel>
-    </PanelGroup>
+      <SplitPanePrimary>
+        <SidebarPanel hrefBase={basePath} config={config} />
+      </SplitPanePrimary>
+      <SplitPaneSecondary>{children}</SplitPaneSecondary>
+    </SplitView>
   );
 };
-
-// Utils
-// -----------------------------------------------------------------------------
-
-const SIDEBAR_MIN_PERCENT = 14;
-const SIDEBAR_DEFAULT_PERCENT = 20;
-const SIDEBAR_MAX_PERCENT = 40;
-const SIDEBAR_MIN_PX = 180;
-const SIDEBAR_DEFAULT_PX = 260;
-const SIDEBAR_MAX_PX = 360;
-
-const calcDefault = (t: number) =>
-  toFixedNumber((SIDEBAR_DEFAULT_PX / t) * 100, 0);
-const calcMin = (t: number) => toFixedNumber((SIDEBAR_MIN_PX / t) * 100, 0);
-const calcMax = (t: number) =>
-  toFixedNumber(Math.min((SIDEBAR_MAX_PX / t) * 100, SIDEBAR_MAX_PERCENT), 0);
-
-function getInitialSizes() {
-  if (typeof window === 'undefined') {
-    return {
-      minSize: SIDEBAR_MIN_PERCENT,
-      maxSize: SIDEBAR_MAX_PERCENT,
-      defaultSize: SIDEBAR_DEFAULT_PERCENT,
-    };
-  }
-
-  // Fallback to `window.innerWidth`, which doesn't include scrollbars but it's
-  // okay for this approximation.
-  let viewportWidth = window.visualViewport?.width || window.innerWidth;
-
-  let minSize = calcMin(viewportWidth);
-  let maxSize = calcMax(viewportWidth);
-  let defaultSize = calcDefault(viewportWidth);
-  return { minSize, maxSize, defaultSize };
-}
-
-/** Takes a value and forces it to the closest min/max if it's outside. */
-export function clamp(
-  value: number,
-  min: number = -Infinity,
-  max: number = Infinity
-): number {
-  let newValue = Math.min(Math.max(value, min), max);
-  return newValue;
-}
-
-/* Takes a value and rounds off to the number of digits. */
-export function toFixedNumber(
-  value: number,
-  digits: number,
-  base: number = 10
-): number {
-  let pow = Math.pow(base, digits);
-  return Math.round(value * pow) / pow;
-}
-
-// Styled components
-// -----------------------------------------------------------------------------
-
-function ResizeHandle(props: Omit<PanelResizeHandleProps, 'className'>) {
-  return (
-    <PanelResizeHandle
-      {...props}
-      className={css({
-        backgroundColor: tokenSchema.color.border.muted,
-        boxSizing: 'border-box',
-        outline: 0,
-        position: 'relative',
-        transition: transition('background-color'),
-        width: tokenSchema.size.border.regular,
-        zIndex: 1,
-
-        // hide when disabled
-        '&[data-panel-resize-handle-enabled=false]': {
-          position: 'absolute', // take no space when hidden
-          visibility: 'hidden',
-        },
-
-        // increase hit area
-        '&::before': {
-          content: '""',
-          position: 'absolute',
-          inset: `calc(${tokenSchema.size.space.small} * -1)`,
-        },
-
-        // drag indicator
-        '&::after': {
-          backgroundColor: tokenSchema.color.alias.backgroundHovered,
-          content: '""',
-          insetBlock: 0,
-          insetInline: `calc(${tokenSchema.size.border.medium} * -1)`,
-          opacity: 0,
-          position: 'absolute',
-          transition: transition('opacity'),
-        },
-        // delay to avoid flicker. user may just be mousing around the screen; wait for intent
-        '&:hover': {
-          backgroundColor: tokenSchema.color.border.neutral,
-          transitionDelay: tokenSchema.animation.duration.regular,
-
-          '&::after': {
-            opacity: 1,
-            transitionDelay: tokenSchema.animation.duration.regular,
-          },
-        },
-        '&[data-resize-handle-active]::after': {
-          backgroundColor: tokenSchema.color.background.accentEmphasis,
-          insetInline: `calc(${tokenSchema.size.border.regular} * -1)`,
-          opacity: 1,
-        },
-      })}
-    />
-  );
-}
diff --git a/packages/keystatic/src/app/shell/sidebar.tsx b/packages/keystatic/src/app/shell/sidebar.tsx
index 6b828efa..681ba447 100644
--- a/packages/keystatic/src/app/shell/sidebar.tsx
+++ b/packages/keystatic/src/app/shell/sidebar.tsx
@@ -1,17 +1,25 @@
 import { useLocalizedStringFormatter } from '@react-aria/i18n';
 import { DismissButton, useModalOverlay } from '@react-aria/overlays';
+import { useUpdateEffect } from '@react-aria/utils';
 import {
   OverlayTriggerState,
   useOverlayTriggerState,
 } from '@react-stately/overlays';
-import { createContext, ReactNode, useContext, useEffect, useRef } from 'react';
+import { createContext, ReactNode, useContext, useRef } from 'react';
 
 import { Badge } from '@keystar/ui/badge';
 import { Box, Flex } from '@keystar/ui/layout';
 import { Blanket } from '@keystar/ui/overlays';
 import { NavList, NavItem, NavGroup } from '@keystar/ui/nav-list';
-import { css, tokenSchema, transition } from '@keystar/ui/style';
+import {
+  breakpoints,
+  css,
+  tokenSchema,
+  transition,
+  useBreakpoint,
+} from '@keystar/ui/style';
 import { Text } from '@keystar/ui/typography';
+import { usePrevious } from '@keystar/ui/utils';
 
 import { Config } from '../../config';
 
@@ -24,6 +32,7 @@ import { SIDE_PANEL_ID } from './constants';
 import { ZapLogo } from './common';
 import { useConfig } from './context';
 import { serializeRepoConfig } from '../repo-config';
+import { typedKeys } from 'emery';
 
 const SidebarContext = createContext<OverlayTriggerState | null>(null);
 export function useSidebar() {
@@ -34,8 +43,24 @@ export function useSidebar() {
   return context;
 }
 
+const breakpointNames = typedKeys(breakpoints);
 export function SidebarProvider(props: { children: ReactNode }) {
-  const state = useOverlayTriggerState({});
+  const matchedBreakpoints = useBreakpoint();
+  const state = useOverlayTriggerState({
+    defaultOpen: matchedBreakpoints.includes('desktop'),
+  });
+
+  let breakpointIndex = breakpointNames.indexOf(matchedBreakpoints[0]);
+  let previousIndex = usePrevious(breakpointIndex) || 0;
+
+  useUpdateEffect(() => {
+    let larger = previousIndex < breakpointIndex;
+    if (larger && breakpointIndex >= 2) {
+      state.open();
+    } else if (breakpointIndex < 2) {
+      state.close();
+    }
+  }, [matchedBreakpoints]);
 
   return (
     <SidebarContext.Provider value={state}>
@@ -84,9 +109,8 @@ export function SidebarDialog(props: { hrefBase: string; config: Config }) {
   const router = useRouter();
 
   // close the sidebar when the route changes
-  useEffect(() => {
+  useUpdateEffect(() => {
     state.close();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [router.href]);
 
   let dialogRef = useRef<HTMLDivElement>(null);
diff --git a/packages/keystatic/src/app/ui.tsx b/packages/keystatic/src/app/ui.tsx
index a59441ec..1ac5b767 100644
--- a/packages/keystatic/src/app/ui.tsx
+++ b/packages/keystatic/src/app/ui.tsx
@@ -21,7 +21,9 @@ import { CreateItem } from './create-item';
 import { DashboardPage } from './dashboard';
 import { ItemPage } from './ItemPage';
 import Provider from './provider';
-import { AppShell, AppShellBody, AppShellRoot, EmptyState } from './shell';
+import { AppShell } from './shell';
+import { PageBody, PageRoot } from './shell/page';
+import { EmptyState } from './shell/empty-state';
 import { SingletonPage } from './SingletonPage';
 import { FromTemplateDeploy } from './onboarding/from-template-deploy';
 import { CreatedGitHubApp } from './onboarding/created-github-app';
@@ -141,15 +143,15 @@ function PageInner({ config }: { config: Config }) {
     <AppShell config={config} currentBranch={branch || ''} basePath={basePath}>
       <NotFoundBoundary
         fallback={
-          <AppShellRoot>
-            <AppShellBody>
+          <PageRoot>
+            <PageBody>
               <EmptyState
                 icon={fileX2Icon}
                 title="Not found"
                 message="This page could not be found."
               />
-            </AppShellBody>
-          </AppShellRoot>
+            </PageBody>
+          </PageRoot>
         }
       >
         {parsedParams === null ? (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df215b7a..9c362187 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -695,7 +695,6 @@ importers:
       react: ^18.2.0
       react-dom: ^18.2.0
       react-element-to-jsx-string: ^15.0.0
-      react-resizable-panels: ^0.0.53
       resize-observer-polyfill: ^1.5.1
       scroll-into-view-if-needed: ^3.0.3
       signal-exit: ^3.0.7
@@ -766,7 +765,6 @@ importers:
       prosemirror-state: 1.4.3
       prosemirror-transform: 1.7.3
       prosemirror-view: 1.31.4
-      react-resizable-panels: 0.0.53_biqbaboplfbrettd7655fr4n2y
       scroll-into-view-if-needed: 3.0.6
       slate: 0.91.4
       slate-history: 0.86.0_slate@0.91.4
@@ -19359,16 +19357,6 @@ packages:
     engines: {node: '>=0.10.0'}
     dev: true
 
-  /react-resizable-panels/0.0.53_biqbaboplfbrettd7655fr4n2y:
-    resolution: {integrity: sha512-lGOJF0Hh5+Y+Usi7x8btmBTi+6CQV1/RKxnj6jVrzvJ9vLbftbSoJPzymOuX8ZCFimlEwP2AKsGtQVKG/KieHA==}
-    peerDependencies:
-      react: ^16.14.0 || ^17.0.0 || ^18.0.0
-      react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0
-    dependencies:
-      react: 18.2.0
-      react-dom: 18.2.0_react@18.2.0
-    dev: false
-
   /react-resize-detector/7.1.2_biqbaboplfbrettd7655fr4n2y:
     resolution: {integrity: sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw==}
     peerDependencies:


Test patch:
diff --git a/design-system/pkg/src/split-view/test/SplitView.test.tsx b/design-system/pkg/src/split-view/test/SplitView.test.tsx
new file mode 100644
index 00000000..706b799b
--- /dev/null
+++ b/design-system/pkg/src/split-view/test/SplitView.test.tsx
@@ -0,0 +1,91 @@
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+
+import { renderWithProvider } from '#test-utils';
+
+import {
+  SplitPanePrimary,
+  SplitPaneSecondary,
+  SplitView,
+  SplitViewProps,
+} from '../index';
+
+const DEFAULT_SIZE = 200;
+const MIN_SIZE = 100;
+const MAX_SIZE = 400;
+const STEP = Math.round((MAX_SIZE - MIN_SIZE) / 10);
+function renderSplitView(props: Partial<SplitViewProps> = {}) {
+  return renderWithProvider(
+    <SplitView
+      defaultSize={DEFAULT_SIZE}
+      minSize={MIN_SIZE}
+      maxSize={MAX_SIZE}
+      {...props}
+    >
+      <SplitPanePrimary>Primary</SplitPanePrimary>
+      <SplitPaneSecondary>Secondary</SplitPaneSecondary>
+    </SplitView>
+  );
+}
+
+// NOTE: This is a very basic test suite. It does not include tests for the
+// resize drag behavior, which is probably better suited to something like
+// Cypress or Playwright.
+describe('split-view/SplitView', () => {
+  it('renders', () => {
+    const { getByText, getByRole } = renderSplitView();
+
+    expect(getByText('Primary')).toBeVisible();
+    expect(getByRole('separator')).toBeVisible();
+    expect(getByText('Secondary')).toBeVisible();
+  });
+
+  it('accepts dom props', () => {
+    const { getByTestId } = renderSplitView({
+      // @ts-ignore
+      'data-testid': 'foo',
+      id: 'bar',
+    });
+
+    expect(getByTestId('foo')).toHaveAttribute('id', 'bar');
+  });
+
+  it('is collapsible', () => {
+    const { getByText, getByRole } = renderSplitView({ isCollapsed: true });
+
+    expect(getByText('Primary')).not.toBeVisible();
+    expect(getByRole('separator', { hidden: true })).toBeVisible();
+    expect(getByText('Secondary')).toBeVisible();
+  });
+
+  it('supports keyboard resize', async function () {
+    let onResize = jest.fn();
+    let user = userEvent.setup();
+    let id = 'test';
+    const { getByRole } = renderSplitView({ id, onResize });
+    const resizeHandle = getByRole('separator');
+
+    expect(resizeHandle).toHaveAttribute('aria-valuemax', '100');
+    expect(resizeHandle).toHaveAttribute('aria-valuemin', '0');
+    expect(resizeHandle).toHaveAttribute(
+      'aria-valuenow',
+      Math.round(
+        ((DEFAULT_SIZE - MIN_SIZE) / (MAX_SIZE - MIN_SIZE)) * 100
+      ).toString()
+    );
+
+    await user.tab();
+    expect(resizeHandle).toHaveFocus();
+
+    await user.keyboard('[ArrowRight]');
+    expect(onResize).toHaveBeenCalledWith(DEFAULT_SIZE + STEP);
+    await user.keyboard('[ArrowRight]');
+    expect(onResize).toHaveBeenCalledWith(DEFAULT_SIZE + STEP * 2);
+    await user.keyboard('[ArrowLeft]');
+    expect(onResize).toHaveBeenCalledWith(DEFAULT_SIZE + STEP);
+    await user.keyboard('[End]');
+    expect(onResize).toHaveBeenCalledWith(MAX_SIZE);
+    await user.keyboard('[Home]');
+    expect(onResize).toHaveBeenCalledWith(MIN_SIZE);
+  });
+});