Elements

Dock

A dock area is a workspace the user arranges: panels in tab groups, groups split side by side or stacked, and docks on the left, right and bottom edges that open, close and resize. A tab dragged onto another group joins it, dragged to a group’s edge splits it, and every panel keeps what it holds as it moves. DockArea owns the behavior: tab drags and drops, which run natively frame by frame, resizing splits and docks, opening and closing docks, zoom and the lock. The look is the app’s: the tabs, the tab bars, the handles, the drop indicator and each panel’s content are what the app renders and styles, and the area draws nothing of its own.

el-dock.tsx
el-dock.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Button, DockArea, type DockAreaLayout, type DockAreaRef } from "nsh/base";
import { desktop, Log, Panel, Split, ui, wide } from "./palette";

// The panels' keys and what each shows. The layout lists the keys; the app
// renders a panel's content from its key, and the content moves with it.
const titles: Record<string, string> = {
  files: "Files",
  readme: "README.md",
  main: "main.rs",
  preview: "Preview",
  terminal: "Terminal",
  problems: "Problems",
};
const lines: Record<string, string[]> = {
  files: ["src/", "  main.rs", "  lib.rs", "README.md", "Cargo.toml"],
  readme: ["# Hopper", "", "A small app with a dockable workspace."],
  main: ["fn main() {", "    hopper::run();", "}"],
  preview: ["Hopper", "A small app with a dockable workspace."],
  terminal: ["$ cargo run", "   Compiling hopper v0.1.0", "    Finished dev in 1.92s"],
  problems: ["No problems"],
};

const initial: DockAreaLayout = {
  center: {
    type: "split",
    axis: "horizontal",
    children: [
      { type: "tabs", panels: ["readme", "main"], activeIndex: 1 },
      { type: "tabs", panels: ["preview"] },
    ],
    sizes: [null, 260],
  },
  left: { layout: { type: "tabs", panels: ["files"] }, size: 180 },
  bottom: { layout: { type: "tabs", panels: ["terminal", "problems"] }, size: 130 },
};

function App() {
  const layout = signal<DockAreaLayout>(initial);
  const locked = signal(false);
  // What shows now, in either presentation: the layout's docks when the area
  // is wide, the drawer or sheet presented when it is narrow. It starts from
  // the layout and follows onDockOpenChange.
  const shown = signal<Record<string, boolean>>({ left: true, bottom: true });
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));
  let area!: DockAreaRef;

  const toolbarButton = (label: string, onPress: () => void, on?: () => boolean) => (
    <Button
      onPress={onPress}
      selected={on?.() ?? false}
      class="rounded-(--radius) px-3 py-1.5 active:opacity-70 selected:bg-muted"
      style={{ borderWidth: 1, borderColor: ui().border }}
    >
      <Text style={{ fontSize: 13, fontWeight: "500", color: ui().text }}>{label}</Text>
    </Button>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>dock</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Drag a tab onto another group, or to a group's edge to split it"
          : "Tap Files or Terminal to open a dock; hold a tab to reorder it"}
      </Text>

      <Split
        grow
        width={720}
        inspector={
          <>
            <Panel title="docks">
              <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
                {toolbarButton("Files", () => area.toggleDock("left"), () => shown().left)}
                {toolbarButton("Terminal", () => area.toggleDock("bottom"), () => shown().bottom)}
                {toolbarButton("Lock", () => locked.set(!locked()), locked)}
                {toolbarButton("Reset", () => layout.set(initial))}
              </View>
            </Panel>
            <Log title="events" lines={events()} minHeight={96} />
          </>
        }
      >
        <View style={{ height: wide() ? 420 : 460, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <DockArea
            ref={area}
            class="flex-1"
            layout={layout()}
            onLayoutChange={(next) => {
              layout.set(next);
              log("onLayoutChange");
            }}
            onDockOpenChange={(placement, open) => {
              shown.set({ ...shown(), [placement]: open });
              log(`${placement} ${open ? "shown" : "hidden"}`);
            }}
            onPanelActiveChange={(panel, active) => active && log(`${titles[panel]} shown`)}
            locked={locked()}
            // The demo's two layouts: the full workspace beside the inspector in a
            // wide window, the phone presentation in one column. Without `compact`
            // the area decides by its own width (under 600 points: compact).
            compact={!wide()}
            groupClass="flex-1"
            tabBarClass="flex-row border-b border-border"
            tabClass="group px-3 py-1.5 selected:bg-muted"
            contentClass="flex-1"
            handleClass="bg-border group-hover:bg-muted-foreground group-active:bg-primary"
            handleAccessibilityLabel="Resize"
            dockClass="bg-muted/40"
            dropIndicatorClass="rounded-(--radius) border-2 border-primary bg-primary/10"
            drawerClass="bg-background border-r border-border"
            sheetClass="rounded-t-(--radius-lg) bg-background"
            backdropClass="bg-black/30"
            renderTab={(tab) => (
              // One view inside the tab: a row holding the title and the close button.
              <View class="flex-row items-center gap-1.5">
                <Text class="text-[13px] text-muted-foreground group-selected:text-foreground">{titles[tab.panel]}</Text>
                {tab.closable && desktop ? (
                  <Button
                    onPress={() => area.closePanel(tab.panel)}
                    accessibilityLabel={`Close ${titles[tab.panel]}`}
                    class="rounded-sm px-1 hover:bg-muted"
                  >
                    <Text class="text-xs text-muted-foreground">×</Text>
                  </Button>
                ) : null}
              </View>
            )}
            renderDragPreview={(tab) => (
              <View class="rounded-(--radius) border border-border bg-background px-3 py-1.5 shadow-md">
                <Text class="text-[13px] text-foreground">{titles[tab.panel]}</Text>
              </View>
            )}
            renderPanel={(panel) => (
              <View class="flex-1 gap-1 p-3">
                {(lines[panel.id] ?? []).map((line) => (
                  <Text class="font-mono text-xs text-foreground">{line || " "}</Text>
                ))}
              </View>
            )}
            renderEmpty={() => (
              <View class="flex-1 items-center justify-center">
                <Text class="text-sm text-muted-foreground">Every panel is closed</Text>
              </View>
            )}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    dock::{DockAreaLayout, DockLayout, DockPanel, DockPlacement, DockRegion, DockTab},
    dock_area, theme,
};
use nsh_core::ui::prelude::*;

fn workspace() -> impl IntoElement {
    let layout = signal(
        DockAreaLayout::new(
            DockLayout::h_split()
                .child(DockLayout::tabs().panel("readme").panel("main"), None)
                .child(DockLayout::tabs().panel("preview"), Some(px(320.0))),
        )
        .with_dock(DockPlacement::Left, DockRegion::new(DockLayout::tabs().panel("files")))
        .with_dock(DockPlacement::Bottom, DockRegion::new(DockLayout::tabs().panel("terminal"))),
    );
    dock_area()
        .layout(move || layout.get())
        .on_layout_change(move |next: &DockAreaLayout| layout.set(next.clone()))
        .handle_style(|s| s.bg(move || theme().border()))
        .tab_bar_style(|s| s.flex_row().border_b_1().border_color(move || theme().border()))
        .tab_style(|s| s.px_3().py_1().selected(|s| s.bg(move || theme().muted())))
        .render_tab(|tab: &DockTab| text(tab.panel.to_string()).text_sm().text_color(move || theme().foreground()))
        .render_panel(|panel: &DockPanel| {
            text(format!("{} content", panel.id)).p_4().text_color(move || theme().foreground())
        })
}

DockArea and the values it takes come from nsh/base, the nsh-base crate in Rust (nsh_base::dock_area(), the values in nsh_base::dock). In Rust, dock_area() makes it, with a setter per prop, a listener per event and the typed style methods; DockLayout describes a layout by the panels’ keys (h_split(), v_split(), tabs(), .child(layout, size), .panel(key)).

#The layout

The layout is a value, DockAreaLayout: the center, and a dock on each of the left, right and bottom edges, each optional. Each region is a tree of two kinds of node:

NodeHoldsFields
{ type: "split" }Other nodes, side by side (axis: "horizontal") or stacked ("vertical")children, and sizes: each child’s length along the axis in points, null to share the rest
{ type: "tabs" }Panels, by key, one shown at a timepanels (the keys, in tab order), activeIndex (the one shown, default 0)

A panel always lives in a tab group: a region with one panel is a tab group of one. A panel’s key is the app’s string for it ("files", a document’s id), and a key appears once in the whole layout. A dock (DockRegion) is its tree (layout), its size across its edge (size, default 200, at least 100), whether it is open (open, default true) and whether dragging its edge below its smallest size closes it (collapsible, default true).

Every node has an id. A layout the app writes may leave ids out: the area assigns them, and every layout it reports carries them. Passing a reported layout back keeps each group’s identity, which the ref’s methods name groups by. The area also tidies what it is given: an empty group or split goes, a split of one child becomes that child, a split inside a split of the same axis joins its parent, activeIndex stays in range, a key listed twice keeps its first place. The layouts it reports are always in that one canonical shape.

The layout is the app’s (layout): every change the user makes (a tab selected, dragged or closed, a split or dock resized, a dock opened or closed) is reported as the whole new layout (onLayoutChange), and the area shows the layout the app passes back. The app changes the layout itself by passing a new value: a saved layout restored, a command that rearranges the workspace. An area without layout keeps its own, starting from defaultLayout, and still reports every change.

#Panels’ content

What a panel shows is the app’s: renderPanel renders it from the panel’s key (panel.id). Every panel of a group is placed and only the displayed one shown, so switching tabs keeps each panel’s content; and when a panel moves (to another group, into a dock, into a zoom and back), the same content moves with it: the signals it created, an input’s text, a scroll position, a web view’s page all stay. The area renders a panel once, when it first appears, and lets its content go when the panel leaves the layout (onPanelRemove).

<DockArea
  layout={layout()}
  onLayoutChange={(v) => layout.set(v)}
  renderPanel={(panel) => (panel.id === "terminal" ? <Terminal /> : <Editor file={panel.id} />)}
  onPanelActiveChange={(panel, active) => active && setTitle(panel)}
  onPanelRemove={(panel) => closeFile(panel)}
/>

panels sets what each panel allows, by key; a panel not listed allows everything:

FieldDefaultDescription
idThe panel’s key.
closabletrueIt may be closed.
zoomabletrueIts group may zoom while it is displayed.
visibletrueIt shows. A hidden panel keeps its place and comes back there; a group or split whose panels are all hidden takes no room.

#Tabs

Each group has a tab bar with a tab per visible panel, in the order of its panels. renderTab renders what a tab shows (its title, an icon, a close button of the app’s); without it a tab shows the panel’s key. The area makes it the tab: a press selects it, a drag moves its panel, screen readers hear it as a tab with its selected state, and tabClass’s selected: classes style the displayed one. A tab’s value says what the app needs to draw it:

FieldTypeDescription
panelstringThe panel’s key.
groupnumberThe id of the tab group it is in.
indexnumberIts place among the group’s visible tabs.
activebooleanIt is the group’s displayed tab.
closablebooleanThe area would close it: it is closable, the area is not locked, and its group may give it away.
zoomedbooleanIts group is zoomed.
let area!: DockAreaRef;

<DockArea
  ref={area}
  layout={layout()}
  onLayoutChange={(v) => layout.set(v)}
  renderTab={(tab) => (
    <View class="flex-row items-center gap-2">
      <Text class="text-sm">{titles[tab.panel]}</Text>
      <If when={tab.closable}>
        <Button onPress={() => area.closePanel(tab.panel)} accessibilityLabel="Close" class="rounded-sm px-1 hover:bg-muted">
          <Text class="text-xs">×</Text>
        </Button>
      </If>
    </View>
  )}
  renderPanel={(panel) => <Editor file={panel.id} />}
/>

#Drag and drop

A tab dragged over a group shows where it would land, and the drop puts it there:

Dropped onThe panel
A tab barJoins that group, between the tabs it is dropped by
The middle of a group’s contentJoins that group
An edge of a group’s content (its outer 35% on each side)Splits that group: a new group of its own on that side

While a drag hovers, the area shows the drop indicator over the part of the group the panel would take (all of its content to join, half of it to split): a part the app styles (dropIndicatorClass), with content of its own if the app renders some (renderDropIndicator, told the group, the side it splits off or the tab bar index). It moves to each new place natively, easing there and fading in on another group as the last one fades out (at once when the user asks for less motion). What follows the pointer or finger is the app’s too (renderDragPreview). A drop is one change of the layout, reported once; a drop that would change nothing changes nothing.

A locked area, a zoomed one, and a group’s last panel when nothing sits beside the group drag nothing.

#Docks

A dock opens and closes without losing its panels or its size: closing it keeps its tree, and opening it shows the same groups at the same size. The edge between a dock and the center is a handle the user drags to resize the dock; it stays at least 100 points and the center keeps at least 100 points, and a collapsible dock let go below 50 points closes and keeps its size for when it opens. The area always fits its bounds: a dock shows at its size within what the area leaves it, and when even that does not fit, the docks shrink with the rest and the splits’ minimum sizes yield. renderDock draws a dock’s own chrome before its layout (a title strip, a collapse button), told its placement, open, size and collapsible; dockClass styles its frame.

Open, close and resize docks from the app through the ref (toggleDock, setDockOpen, setDockSize), which work in both presentations, or by writing the dock’s open and size in the layout it passes (open is the full presentation’s). onDockOpenChange reports each time a dock shows or hides, measured against the layout’s open (an area that is compact from the start reports each layout-open dock hidden), so a toggle that starts from the layout’s open and follows onDockOpenChange always shows the dock’s state:

const terminalOpen = signal(true);

<DockArea
  ref={area}
  layout={layout()}
  onLayoutChange={(v) => layout.set(v)}
  onDockOpenChange={(placement, open) => placement === "bottom" && terminalOpen.set(open)}
  renderPanel={(panel) => <Editor file={panel.id} />}
/>

<Button selected={terminalOpen()} onPress={() => area.toggleDock("bottom")} class="px-3 py-1.5 selected:bg-muted">
  <Text>Terminal</Text>
</Button>

#Zoom

A zoomed group fills the whole area until it is zoomed out or leaves the layout (area.toggleZoom(group), and area.toggleZoom() to zoom out), and onZoomChange reports the panel it displays, or null. The zoom belongs to the group, so it stays zoomed when its displayed panel closes and the next one takes over. A panel with zoomable: false keeps its group from zooming while it is displayed.

#Locking

locked keeps the arrangement as it is: no tab drags, no drops, no closes. Selecting tabs, resizing and opening and closing docks still work, so a locked workspace is still a workspace to use.

#Methods

ref={area} gives the area’s methods (DockAreaRef), for commands, shortcuts and menus of the app’s. Each edit is reported with onLayoutChange as the user’s are.

MethodDescription
layout()Promise<DockAreaLayout>: the layout shown.
addPanel(panel, placement?, size?)Adds a panel to a region ("center", the default, "left", "right", "bottom"), into its first tab group, displayed; a missing dock is made, size across. A panel the area holds moves there.
removePanel(panel)Removes a panel, whatever its options say.
closePanel(panel)Closes a panel as its tab would: unless the area is locked, the panel is not closable, or it is the last panel of a group with nothing beside it.
movePanel(panel, target)Moves a panel to target: { group, side?, index?, activate?, size? }, into tab group group (at index) or a new group on its side (size points along the new split).
splitAt(group, panel, side)Puts a panel in a new tab group on a side ("left", "right", "top", "bottom") of a container.
selectPanel(panel)Displays a panel in its group.
selectTab(group, index)Displays a tab of a group.
toggleDock(placement), setDockOpen(placement, open)Opens or closes a dock.
setDockSize(placement, size)Sets a dock’s size across its edge (at least 100).
toggleZoom(group?)Zooms a group in, or out when it is zoomed; without group, the zoomed group out.
zoomedGroup()Promise<number | null>: the zoomed group’s id.
groupOf(panel)Promise<number | null>: the id of the tab group holding a panel.

In Rust, component_ref::<DockArea>() and .component_ref(&r) on the area give a ref whose r.api::<DockAreaApi>() has the same methods in snake case.

#Saving and restoring

A reported layout is plain data, so saving it is JSON.stringify(layout) into storage, and restoring it is passing the parsed value as layout. onLayoutChange reports every change, a resize and a tab selection included, so write to storage once the changes settle (a short timer reset on each report).

In Rust, DockAreaLayout::to_state and from_state read and write DockAreaState, the layout file format gpui-base’s dock uses too.

#Phones and narrow windows

The dock adapts to its own width. An area 600 points wide and wider (in a window on a desktop, the web, a tablet, an iPad full screen, a foldable open) is the whole workspace: splits, groups and docks side by side, as a tablet’s multi-pane apps are. With a finger on a phone or tablet, a tab drag starts on a long press, as iPadOS drag and drop does, so a tab bar still scrolls under a swipe, and handles take a wider touch area with the same look.

An area narrower than 600 points (on a phone, an iPad in Slide Over, in a narrow desktop or browser window, or in a narrow column of a wide one) presents the same layout for one screen, as iPad’s split views collapse their columns and Material Design’s supporting pane becomes a bottom sheet:

  • The center shows one group at a time, full size. Its tab bar lists every visible panel of the center, and selecting a panel of another group makes that group the one shown.
  • The left and right docks are drawers over the center from their edge, as wide as the dock, and the bottom dock is a sheet as tall as the dock. One shows at a time, when the app presents it (toggleDock, setDockOpen, which dismiss the other); it slides in and out, and a press on its backdrop, Escape or back dismiss it. Entering the compact presentation shows none.
  • A tab dragged (a finger after a long press, a pointer at once) moves within the tab bar to reorder it.

Every panel stays placed in both presentations, so switching keeps its content. The drawer or sheet shown is the compact presentation’s own: the layout’s open belongs to the full presentation, and presenting or dismissing a drawer reports no onLayoutChange, so an area that widens again shows the docks exactly as they were. onDockOpenChange reports both: a dock whose layout open changes in the full presentation, a drawer or sheet shown or dismissed in the compact one, and each dock whose shown state changes when the presentation switches. In renderDock, open is whether the dock shows in the current presentation. The layout stays the same value too, so an area that crosses 600 points (a foldable opening, an iPad joining Split View, a window or a column resized) keeps every panel where it was, and the app saves one layout. The area’s width decides once it is laid out (the window’s width stands in before that), and compact chooses the presentation instead of the width. The drawers, the sheet and the backdrop are parts the app styles (drawerClass, sheetClass, backdropClass), as the rest of the area.

<DockArea
  layout={layout()}
  onLayoutChange={(v) => layout.set(v)}
  drawerClass="bg-background border-r border-border"
  sheetClass="rounded-t-(--radius-lg) bg-background"
  backdropClass="bg-black/40"
  renderTab={(tab) => <Text>{titles[tab.panel]}</Text>}
  renderPanel={(panel) => <Editor file={panel.id} />}
/>

#Props

PropRustTypeDefaultDescription
layout.layout(..)DockAreaLayoutnoneThe layout shown (controlled): pass back what onLayoutChange reports.
defaultLayout.default_layout(..)DockAreaLayoutan empty centerThe layout at first, when layout is not set; the area keeps its own from then on.
locked.locked(..)booleanfalseNo tab drags, drops or closes.
compact.compact(..)booleanby widthThe phone presentation (true) or the full one (false); without it, compact while the area is narrower than 600 points.
panels.panels(..)DockPanelOptions[][]What each panel allows, by key: closable, zoomable, visible.
renderPanel.render_panel(|panel| ..)(panel: DockPanel) => JSX.ElementnoneA panel’s content, by its key (panel.id).
renderTab.render_tab(|tab| ..)(tab: DockTab) => JSX.Elementthe keyWhat a tab shows.
renderDropIndicator.render_drop_indicator(|drop| ..)(drop: DockDropIndicator) => JSX.ElementnoneWhat is inside the drop indicator: group, side, index.
renderDock.render_dock(|dock| ..)(dock: DockInfo) => JSX.ElementnoneA dock’s own chrome, before its layout: placement, open (shown in the current presentation), size, collapsible.
renderEmpty.render_empty(|placement| ..)(placement: DockPlacement) => JSX.ElementnoneWhat an empty center shows.
renderDragPreview.render_drag_preview(|tab| ..)(tab: DockTab) => JSX.ElementnoneWhat follows the pointer or finger while a tab is dragged.
groupClass, groupStyle.group_style(|s| ..)string, StyleProp<ViewStyle>noneEach tab group’s frame.
tabBarClass, tabBarStyle.tab_bar_style(|s| ..)string, StyleProp<ViewStyle>noneEach tab bar (flex-row for a row).
tabClass, tabStyle.tab_style(|s| ..)string, StyleProp<ViewStyle>noneEach tab; selected: for the displayed one.
contentClass, contentStyle.content_style(|s| ..)string, StyleProp<ViewStyle>noneEach group’s content frame, where its panels show.
dropIndicatorClass, dropIndicatorStyle.drop_indicator_style(|s| ..)string, StyleProp<ViewStyle>noneThe drop indicator.
handleClass, handleStyle.handle_style(|s| ..)string, StyleProp<ViewStyle>noneThe indicator of each handle, between a split’s groups and at each dock’s edge; group-hover: and group-active: follow the handle. It is 1 point across the handle and runs its full length unless it says otherwise, so bg-border is a hairline either way; w-[3px] / h-[3px] thicken it.
handleThumbClass, handleThumbStyle.handle_thumb_class(..), .handle_thumb_style(|s| ..)string, StyleProp<ViewStyle>noneEach handle’s thumb: a grip centered on the line, drawn upright as on a vertical line and turned a quarter on a horizontal one, so one class serves both axes. It stays the same view while the handle is hovered, pressed and dragged, so group-hover: and group-active: classes transition it (h-11 w-[3px] rounded-full bg-muted-foreground opacity-0 scale-y-0 transition group-hover:opacity-40 group-hover:scale-y-[0.45] group-active:opacity-90 group-active:scale-y-100).
dockClass, dockStyle.dock_style(|s| ..)string, StyleProp<ViewStyle>noneEach dock’s frame.
drawerClass, drawerStyle.drawer_style(|s| ..)string, StyleProp<ViewStyle>noneCompact: the side docks’ drawers.
sheetClass, sheetStyle.sheet_style(|s| ..)string, StyleProp<ViewStyle>noneCompact: the bottom dock’s sheet.
backdropClass, backdropStyle.backdrop_style(|s| ..)string, StyleProp<ViewStyle>noneCompact: the backdrop behind an open drawer or sheet, whose press closes it.
handleAccessibilityLabel.handle_accessibility_label(..)stringnoneWhat screen readers say for every handle, between a split’s groups and at each dock’s edge.
ref.component_ref(&r) (component_ref::<DockArea>())DockAreaRefThe area’s methods.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe area itself: its size in its parent (flex-1), its background. It stays within its bounds and takes no minimum size from its content.

#Events

EventRustDescription
onLayoutChange.on_layout_change(|layout: &DockAreaLayout| ..)The layout changed (a drag, a selection, a resize, a dock opened or closed, a method): the whole new layout, every id in it.
onPanelActiveChange.on_panel_active_change(|e: &DockAreaEvent| ..) (PanelActiveChange(panel, active))A panel became, or stopped being, its group’s displayed tab (a closed dock displays none). Once per change; a panel that leaves is not told false: onPanelRemove is its end.
onPanelRemove.on_panel_remove(|panel: &PanelId| ..)A panel left the area for good: closed, removed, or missing from a new layout. A panel that moves never hears it.
onZoomChange.on_zoom_change(|panel: &Option<PanelId>| ..)A group zoomed in (the panel it displays) or out (null).
onDockOpenChange.on_dock_open_change(|e: &DockAreaEvent| ..) (DockOpenChange(placement, open))A dock showed or hid: its layout open changed in the full presentation, its drawer or sheet was shown or dismissed in the compact one, or the presentation switched.

#Parts

PartInsideStyled withStates
The areaclass, style
A tab groupa regiongroupClass, groupStyle
A tab bara grouptabBarClass, tabBarStyle
A taba tab bartabClass, tabStyle; renderTabselected:, active:, hover:
A group’s contenta groupcontentClass, contentStyle; renderPanel
A handle’s indicatorbetween a split’s groups, at a dock’s edgehandleClass, handleStylegroup-hover:, group-active:
A handle’s thumbcentered on a handle’s linehandleThumbClass, handleThumbStylegroup-hover:, group-active: (pressed until the drag ends)
A dockan edgedockClass, dockStyle; renderDock
The drop indicatorover a group during a dragdropIndicatorClass, dropIndicatorStyle; renderDropIndicator
The drag previewunder the pointer or fingerrenderDragPreview
The empty centerthe centerrenderEmpty
A drawer, the sheet, the backdropover the center, compactdrawerClass, sheetClass, backdropClass (and their styles)

#Accessibility

  • Each tab is a tab with its selected state for screen readers, as Tab is, and activating it selects it. Its label is the text renderTab draws.
  • Every handle, between a split’s groups and at each dock’s edge, is an adjustable element labeled handleAccessibilityLabel. The handles between a split’s groups are Resizable’s: a screen reader moves them 10 points at a time, reported with onLayoutChange.
  • In the compact presentation, Escape and back dismiss the drawer or sheet shown.

See Accessibility for labels, roles and announcements in general.

#Platform notes

  • A pointer (a mouse, a trackpad, a pen hovering over the screen) drags a tab once it moves 4 points with the button down, on every platform: an iPad with a trackpad and a narrow browser window driven by a mouse drag as a desktop does. On macOS, Windows, Linux and GPUI every press counts as a pointer’s, a finger on a touchscreen laptop’s included.
  • A finger on iOS, iPadOS and Android drags a tab after a long press, as iPadOS drag and drop does, so a swipe over the tab bar scrolls it.
  • Desktops and the web: handles show the resize cursor of their axis.
  • Phones and narrow areas: the compact presentation, with drawers and a bottom sheet.
  • Every platform draws what the app styles: tabs, tab bars, handles, the drop indicator, docks, drawers and the sheet.