Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

LayoutReact

Splitter

Panels side by side, or stacked, with a Separator between each pair that resizes the two around it.

Rendered by React 19.3.1

Loading React…
import { Splitter } from '@hamur/react/splitter'

export default function SplitterExample() {
  return (
    <Splitter.Root className="splitter">
      <Splitter.Panel
        value="files"
        aria-label="Files"
        defaultSize={35}
        minSize={20}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Files</p>
          <p className="muted">index.ts</p>
          <p className="muted">styles.css</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel
        value="editor"
        aria-label="Editor"
        minSize={30}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Editor</p>
          <p className="muted">Drag the line, or focus it and press the arrow keys.</p>
        </div>
      </Splitter.Panel>
    </Splitter.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Splitter.Root>
  <Splitter.Panel />
  <Splitter.Separator />
</Splitter.Root>

Examples

Vertical

orientation vertical: Editor above Terminal, and the line moves with the up and down arrows.

SplitterVertical.tsx
import { Splitter } from '@hamur/react/splitter'

export default function SplitterVerticalExample() {
  return (
    <Splitter.Root className="splitter splitter-tall" orientation="vertical">
      <Splitter.Panel
        value="editor"
        aria-label="Editor"
        defaultSize={60}
        minSize={25}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Editor</p>
          <p className="muted">The line moves with ArrowUp and ArrowDown.</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel
        value="terminal"
        aria-label="Terminal"
        minSize={20}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Terminal</p>
          <p className="muted">$ bun test</p>
        </div>
      </Splitter.Panel>
    </Splitter.Root>
  )
}

Three

Three panels with minSize and maxSize, named by the headings inside them, so each line reports its own panel's range.

SplitterThree.tsx
import { Splitter } from '@hamur/react/splitter'

export default function SplitterThreeExample() {
  return (
    <Splitter.Root className="splitter">
      <Splitter.Panel
        value="inbox"
        aria-labelledby="splitter-three-inbox"
        defaultSize={25}
        minSize={20}
        maxSize={40}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <h3 id="splitter-three-inbox" className="splitter-pane-title">
            Inbox
          </h3>
          <p className="muted">20% to 40%</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel
        value="message"
        aria-labelledby="splitter-three-message"
        minSize={30}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <h3 id="splitter-three-message" className="splitter-pane-title">
            Message
          </h3>
          <p className="muted">At least 30%</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel
        value="details"
        aria-labelledby="splitter-three-details"
        defaultSize={25}
        minSize={15}
        maxSize={30}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <h3 id="splitter-three-details" className="splitter-pane-title">
            Details
          </h3>
          <p className="muted">15% to 30%</p>
        </div>
      </Splitter.Panel>
    </Splitter.Root>
  )
}

Collapsible

A collapsible sidebar that collapses to a 14% rail of icons with a flex-grow transition, from Enter, a double-click or a Sidebar button that calls actionsRef.

SplitterCollapsible.tsx
import * as React from 'react'
import { Splitter } from '@hamur/react/splitter'

const links = ['Inbox', 'Drafts', 'Sent']

export default function SplitterCollapsibleExample() {
  const actions = React.useRef<Splitter.Root.Actions | null>(null)
  const [collapsed, setCollapsed] = React.useState(false)
  return (
    <div className="stack splitter-example">
      <div className="splitter-toolbar">
        <button
          type="button"
          className="button"
          aria-expanded={!collapsed}
          aria-controls="splitter-sidebar"
          onClick={() =>
            collapsed ? actions.current?.expand('sidebar') : actions.current?.collapse('sidebar')
          }
        >
          Sidebar
        </button>
      </div>
      <Splitter.Root
        className="splitter splitter-collapsible"
        actionsRef={actions}
        onValueChange={(value) => setCollapsed(value.sidebar < 30)}
      >
        <Splitter.Panel
          value="sidebar"
          id="splitter-sidebar"
          aria-label="Sidebar"
          defaultSize={35}
          minSize={30}
          collapsible
          collapsedSize={14}
          className="splitter-panel"
        >
          <nav className="splitter-nav" aria-label="Mail">
            {links.map((link) => (
              <a key={link} href={`#${link.toLowerCase()}`} className="splitter-nav-item">
                <span className="splitter-nav-icon" aria-hidden="true" />
                <span className="splitter-nav-label">{link}</span>
              </a>
            ))}
          </nav>
        </Splitter.Panel>
        <Splitter.Separator className="splitter-separator" />
        <Splitter.Panel value="main" aria-label="Main" minSize={40} className="splitter-panel">
          <div className="splitter-pane">
            <p className="splitter-pane-title">Main</p>
            <p className="muted">
              Enter or a double-click on the line collapses the sidebar to a rail.
            </p>
          </div>
        </Splitter.Panel>
      </Splitter.Root>
    </div>
  )
}

Nested

A vertical Root inside a panel of a horizontal one.

SplitterNested.tsx
import { Splitter } from '@hamur/react/splitter'

export default function SplitterNestedExample() {
  return (
    <Splitter.Root className="splitter splitter-tall">
      <Splitter.Panel
        value="explorer"
        aria-label="Explorer"
        defaultSize={30}
        minSize={20}
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Explorer</p>
          <p className="muted">src</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel value="workspace" minSize={40} className="splitter-panel">
        <Splitter.Root className="splitter-inner" orientation="vertical">
          <Splitter.Panel
            value="editor"
            aria-label="Editor"
            defaultSize={65}
            minSize={25}
            className="splitter-panel"
          >
            <div className="splitter-pane">
              <p className="splitter-pane-title">Editor</p>
              <p className="muted">A Root inside a Panel</p>
            </div>
          </Splitter.Panel>
          <Splitter.Separator className="splitter-separator" />
          <Splitter.Panel
            value="terminal"
            aria-label="Terminal"
            minSize={20}
            className="splitter-panel"
          >
            <div className="splitter-pane">
              <p className="splitter-pane-title">Terminal</p>
            </div>
          </Splitter.Panel>
        </Splitter.Root>
      </Splitter.Panel>
    </Splitter.Root>
  )
}

Pixel

A sidebar at 140 px between 100 and 200 px with resizeBehavior set to keep its width; the box resizes from its corner.

SplitterPixel.tsx
import { Splitter } from '@hamur/react/splitter'

export default function SplitterPixelExample() {
  return (
    <Splitter.Root className="splitter splitter-resizable">
      <Splitter.Panel
        value="sidebar"
        aria-label="Sidebar"
        defaultSize="140px"
        minSize="100px"
        maxSize="200px"
        resizeBehavior="preserve-pixel-size"
        className="splitter-panel"
      >
        <div className="splitter-pane">
          <p className="splitter-pane-title">Sidebar</p>
          <p className="muted">100 to 200 px</p>
        </div>
      </Splitter.Panel>
      <Splitter.Separator className="splitter-separator" />
      <Splitter.Panel value="main" aria-label="Main" minSize="120px" className="splitter-panel">
        <div className="splitter-pane">
          <p className="splitter-pane-title">Main</p>
          <p className="muted">Resize the box from its corner: the sidebar keeps its width.</p>
        </div>
      </Splitter.Panel>
    </Splitter.Root>
  )
}

Controlled

value in the example's state: onValueChange writes it and its reason, and three buttons outside set it.

SplitterControlled.tsx
import * as React from 'react'
import { Splitter } from '@hamur/react/splitter'

const layouts = [
  { code: 30, preview: 70 },
  { code: 50, preview: 50 },
  { code: 70, preview: 30 },
]

export default function SplitterControlledExample() {
  const [value, setValue] = React.useState<Record<string, number>>({ code: 50, preview: 50 })
  const [reason, setReason] = React.useState('none yet')
  return (
    <div className="stack splitter-example">
      <Splitter.Root
        className="splitter"
        value={value}
        onValueChange={(next, details) => {
          setValue(next)
          setReason(details.reason)
        }}
      >
        <Splitter.Panel value="code" aria-label="Code" minSize={20} className="splitter-panel">
          <div className="splitter-pane">
            <p className="splitter-pane-title">Code</p>
          </div>
        </Splitter.Panel>
        <Splitter.Separator className="splitter-separator" />
        <Splitter.Panel
          value="preview"
          aria-label="Preview"
          minSize={20}
          className="splitter-panel"
        >
          <div className="splitter-pane">
            <p className="splitter-pane-title">Preview</p>
          </div>
        </Splitter.Panel>
      </Splitter.Root>
      <div className="splitter-toolbar">
        {layouts.map((layout) => (
          <button
            key={layout.code}
            type="button"
            className="button"
            onClick={() => setValue(layout)}
          >
            {layout.code} / {layout.preview}
          </button>
        ))}
      </div>
      <p className="muted">
        Code {Math.round(value.code)}% · Preview {Math.round(value.preview)}% · {reason}
      </p>
    </div>
  )
}

Persist

onValueCommitted writes the layout to a cookie that defaultValue reads on the next load; an app reads it on the server.

SplitterPersist.tsx
import * as React from 'react'
import { Splitter } from '@hamur/react/splitter'

const COOKIE = 'splitter-layout'

// An app reads the cookie on the server and passes it as defaultValue; the showcase has no server,
// so the Root mounts once the browser has read it.
function readLayout(): Record<string, number> | undefined {
  if (typeof document === 'undefined') return undefined
  const entry = document.cookie.split('; ').find((c) => c.startsWith(`${COOKIE}=`))
  if (!entry) return undefined
  try {
    return JSON.parse(decodeURIComponent(entry.slice(COOKIE.length + 1)))
  } catch {
    return undefined
  }
}

function writeLayout(value: Record<string, number>) {
  document.cookie = `${COOKIE}=${encodeURIComponent(JSON.stringify(value))}; path=/; max-age=31536000; samesite=lax`
}

const savedText = (value: Record<string, number> | undefined) =>
  value ? `Saved ${Math.round(value.list)} / ${Math.round(value.reader)}` : 'Nothing saved yet'

export default function SplitterPersistExample() {
  const [initial, setInitial] = React.useState<Record<string, number> | null>(null)
  const [saved, setSaved] = React.useState<Record<string, number> | undefined>(undefined)
  React.useEffect(() => {
    const layout = readLayout()
    setSaved(layout)
    setInitial(layout ?? { list: 40, reader: 60 })
  }, [])
  if (!initial) return <div className="stack splitter-example" />
  return (
    <div className="stack splitter-example">
      <Splitter.Root
        className="splitter"
        defaultValue={initial}
        onValueCommitted={(value) => {
          writeLayout(value)
          setSaved(value)
        }}
      >
        <Splitter.Panel value="list" aria-label="List" minSize={25} className="splitter-panel">
          <div className="splitter-pane">
            <p className="splitter-pane-title">List</p>
          </div>
        </Splitter.Panel>
        <Splitter.Separator className="splitter-separator" />
        <Splitter.Panel value="reader" aria-label="Reader" minSize={25} className="splitter-panel">
          <div className="splitter-pane">
            <p className="splitter-pane-title">Reader</p>
            <p className="muted">Resize, then reload the page: the layout comes back.</p>
          </div>
        </Splitter.Panel>
      </Splitter.Root>
      <p className="muted">{savedText(saved)}</p>
    </div>
  )
}

API reference

22 props across 3 parts, in React syntax. className, style, render are listed once, under Shared props.

PanelSplitter.Panel

A resizable pane. Its size is its entry in the Root's value. Renders a <div>.

Panel props
PropTypeDefault
valueThe panel's key in the Root's value.string—
defaultSizeThe panel's size when no value or defaultValue entry gives one: a number in percent, orSplitterSize—
minSizeThe smallest size the panel can have, in percent or with a unit.SplitterSize0
maxSizeThe largest size the panel can have, in percent or with a unit.SplitterSize100
collapsibleWhether the panel collapses to its collapsedSize below its threshold.booleanfalse
collapsedSizeThe size of the panel when collapsed, in percent or with a unit.SplitterSize0
collapseThresholdHow far above collapsedSize a shrinking panel collapses, in percent or with a unit.SplitterSize—
resizeBehaviorHow the panel's size follows a change of the container's size: 'preserve-pixel-size'SplitterResizeBehavior'preserve-relative-size'
disabledWhether the panel neither gives nor takes space when a Separator moves.booleanfalse
Panel data attributes
Data attributeDescription
data-orientationThe layout axis.
data-collapsedPresent when the panel is collapsed to its collapsedSize.
data-disabledPresent when the panel is disabled.

RootSplitter.Root

Groups the panels and the separators between them, and lays them out on its axis. Renders a <div>.

Root props
PropTypeDefault
valueEach panel's size in percent of the space the panels share, keyed by the panel's value.SplitterValue—
defaultValueThe panels' sizes when the component is initially rendered, in percent, keyed by the panel'sSplitterValue—
onValueChangeEvent handler called on every change of the layout, including each move of a drag.((value: SplitterValue, eventDetails: SplitterRootChangeEventDetails) => void)—
onValueCommittedEvent handler called when a change is committed: pointer release, each key press,((value: SplitterValue, eventDetails: SplitterRootChangeEventDetails) => void)—
orientationThe layout axis: 'horizontal' puts the panels side by side.SplitterOrientation'horizontal'
disabledWhether no Separator moves.booleanfalse
stepThe percent an arrow key moves a Separator by.number1
largeStepThe percent Shift+Arrow, Page Up and Page Down move a Separator by.number10
localeThe locale of the default aria-valuetext.Intl.LocalesArgument—
actionsRefA ref to imperative actions: resize(panel, size), collapse(panel), expand(panel) andReact.RefObject<SplitterRootActions | null>—
Root data attributes
Data attributeDescription
data-orientationThe layout axis.
data-draggingPresent while a Separator is being dragged.
data-disabledPresent when the splitter is disabled.

SeparatorSplitter.Separator

The line between two panels that resizes them, by pointer and by keyboard. It is static on the server and until the panels around it are known. Renders a <div>.

Separator props
PropTypeDefault
primaryPanelThe panel the separator reports and names itself after: the one before it or after it.SplitterPrimaryPanel'before'
disabledWhether the separator is static: not focusable, no values, does not move.booleanfalse
getAriaValueTextAccepts a function which returns a string value that provides a user-friendly name for the((formattedValue: string, value: number) => string)—
Separator data attributes
Data attributeDescription
data-orientationThe line the separator draws: vertical in a horizontal splitter.
data-draggingPresent while the separator is being dragged.
data-disabledPresent when the separator is disabled.
data-at-minPresent when the primary panel is at the smallest size it can reach.
data-at-maxPresent when the primary panel is at the largest size it can reach.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
ArrowLeftArrowRightMove the line left or right by step, 1% by defaultSeparator
ShiftArrowLeftShiftArrowRightMove the line by largeStep, 10% by defaultSeparator
ArrowUpArrowDownMove the line up or down when orientation is vertical; in a horizontal Root, nothingSeparator
ArrowLeftArrowRightStill move the line left and right in a right-to-left layoutSeparator
PageUpPageDownGrow or shrink the primary panel by largeStepSeparator
HomeEndShrink or grow the primary panel as far as every panel's limits allowSeparator
EnterCollapses a collapsible primary panel, and restores its size on a second pressSeparator
TabSkips the content of a panel collapsed to 0Panel

Accessibility

What the parts set
PartSetsSo that
Separatorrole="separator" with tabindex="0", aria-valuenow, aria-valuemin and aria-valuemax of its primary panel, in the range every panel's limits leave ita reader hears the panel's size and how far it can go
Separatoraria-orientation of the line: vertical in a horizontal Root, horizontal in a vertical one; data-orientation the samethe arrows a reader is told to use are the ones that move it
Separatoraria-valuetext "35%" (from getAriaValueText when given, in the Root's locale), aria-controls the primary panel's idthe value is said as a percentage and the separator points at what it resizes
Separatorits own aria-label or aria-labelledby, else the primary panel's; a development warning when neither has oneeach line has a name
Separatorstatic (the role and aria-orientation only, no Tab stop) on the server and until the panels around it are known, and when disableda focusable separator never lacks its value
Panelinert while collapsed to 0; a rail with a collapsedSize above 0 stays usableTab, find and a screen reader skip what is not shown
Panelno role and no aria-disabled; the app makes a pane a landmark with render (a nav, a main)the layout adds no landmark of its own
Rootno role and no ARIA; data-dragging while a line is dragged, with user-select: none inlinethe layout is not announced, and a drag does not select text

Focus order

  1. In document order: a panel's content, then the separator after it, then the next panel's content. Nothing takes focus on mount.
  2. A press on a separator focuses it without a focus ring, and focus stays there after the drag, so the arrows can refine what the pointer did.
  3. The content of a panel collapsed to 0 is skipped until the panel is restored.
  4. In nested splitters each separator moves only its own Root's panels.