react-mosaic

A React tiling window manager

RAW Doc

id: custom-toolbar title: Custom toolbars sidebar_position: 1

Custom toolbars

Every MosaicWindow renders a title bar with a default set of controls on the
right. You can replace that set entirely, add to it, or style it — all by
passing React nodes to the toolbarControls prop.

The default toolbar

Out of the box you get split, expand and remove buttons. The presets are
exported so you can reuse them:

tsx
import {
  DEFAULT_CONTROLS_WITH_CREATION,
  DEFAULT_CONTROLS_WITHOUT_CREATION,
} from 'react-mosaic-component';
  • DEFAULT_CONTROLS_WITH_CREATION — Split, Expand, Remove
  • DEFAULT_CONTROLS_WITHOUT_CREATION — Expand, Remove (no Split)

Passing toolbarControls={DEFAULT_CONTROLS_WITHOUT_CREATION} disables the
split button without forcing you to rebuild the toolbar.

Editable example — change a button color live

Edit the buttonColor constant below and watch the toolbar update. This is
the entire value of live-coding docs: the example is the API surface.

tsx live
function CustomToolbarExample() {
  const buttonColor = '#106ba3'; // try '#db3737', '#0f9960', '#d9822b'

  const toolbar = (
    
       alert('custom action!')}
      >
        Custom
      
      <Separator />
      <ExpandButton />
      <RemoveButton />
    
  );

  return (
    
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow
            path={path}
            title={`Panel ${id}`}
            toolbarControls={toolbar}
          >
            
              Edit `buttonColor` above to re-theme the toolbar.
            
          </MosaicWindow>
        )}
        initialValue={{
          type: 'split',
          direction: 'row',
          children: ['left', 'right'],
        }}
      />
    
  );
}

Building your own buttons

Each default button is a thin wrapper around DefaultToolbarButton, which
handles the icon+label+click plumbing. You can compose your own:

tsx live
function CustomButtonExample() {
  function StarButton() {
    return (
      <DefaultToolbarButton
        title="Star this panel"
        className="custom-star-button"
        onClick={() => alert('starred!')}
      >
        ★
      </DefaultToolbarButton>
    );
  }

  const toolbar = (
    
      <StarButton />
      <Separator />
      <ExpandButton />
      <RemoveButton />
    
  );

  return (
    
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow
            path={path}
            title={`Panel ${id}`}
            toolbarControls={toolbar}
          >
            Panel {id}
          </MosaicWindow>
        )}
        initialValue={{ type: 'split', direction: 'row', children: ['a', 'b'] }}
      />
    
  );
}

Accessing window actions from a custom button

Custom buttons often need to operate on the panel they live in — remove it,
expand it, replace its content. MosaicWindowContext exposes those actions:

tsx
import { useContext } from 'react';
import {
  MosaicWindowContext,
  DefaultToolbarButton,
} from 'react-mosaic-component';

function DuplicateButton() {
  const { mosaicWindowActions } = useContext(MosaicWindowContext);

  return (
    <DefaultToolbarButton
      title="Duplicate"
      onClick={() => mosaicWindowActions.split()}
    >
      ⎘
    </DefaultToolbarButton>
  );
}

Similarly, MosaicContext gives you tree-level actions (hide, expand,
remove, replaceWith, updateTree) for operations that aren't scoped to a
single window.

Toolbars inside tab groups

Tab groups render their own toolbar: tab buttons on the left, then on the
right a library-owned drag handle followed by a controls cluster (add tab,
split, remove by default). You can reshape the controls cluster without
giving up drag-and-drop — that stays with the library.

The customization props, ordered from least to most invasive:

Prop What it swaps Library keeps owning
renderTabTitle Content inside each tab button Drag, close, DnD
renderTabToolbarControls The right-side controls cluster (add, split, remove, …) Drag handle, drop targets
renderTabToolbar The entire tab bar (escape hatch) Nothing — you re-wire DnD yourself

Prefer the first two. renderTabToolbar is a last resort; opting into it
means re-implementing tab rendering, drop targets, and drag handles.

Composing your own controls cluster

renderTabToolbarControls receives { tabs, activeTabIndex, path, mosaicId }
and returns a ReactNode. You decide which buttons are present, in what
order, and when. The library injects its drag handle as a sibling before
your controls, so you never touch react-dnd.

The tab-specific buttons are exported so you can drop them in directly:

tsx
import {
  DefaultAddTabButton,
  TabSplitButton,
  TabRemoveButton,
  TabExpandButton,
} from 'react-mosaic-component';

Per-tab controls

Show buttons that depend on which tab is active — e.g. a preview action
that's only meaningful for Markdown files.

tsx live
function PerTabControlsExample() {
  const isMarkdown = (id) => typeof id === 'string' && id.endsWith('.md');

  return (
    
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`${id}`}>
            
              Open: **{id}**
              {isMarkdown(id) && (Preview available)}
            
          </MosaicWindow>
        )}
        renderTabToolbarControls={({ tabs, activeTabIndex, path }) => (
          <>
            {isMarkdown(tabs[activeTabIndex]) && (
              <DefaultToolbarButton
                title="Preview"
                onClick={() => alert('preview ' + tabs[activeTabIndex])}
              >
                👁
              </DefaultToolbarButton>
            )}
            <DefaultAddTabButton path={path} />
            <TabSplitButton path={path} />
            <TabRemoveButton path={path} />
          </>
        )}
        initialValue={{
          type: 'tabs',
          tabs: ['readme.md', 'index.ts', 'notes.md'],
          activeTabIndex: 0,
        }}
      />
    
  );
}

Switch tabs: the preview button appears only for .md files. The drag
handle between the controls and the tab row is still there — you never had
to think about it.

Capping the number of tabs

Omit DefaultAddTabButton when the tab group is full. Because you compose
the cluster yourself, conditional rendering is just a React expression.

tsx live
function TabLimitExample() {
  const MAX_TABS = 4;
  let counter = 0;

  return (
    
      <Mosaic
        createNode={() => `tab-${++counter}`}
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`Tab ${id}`}>
            Panel {id}
          </MosaicWindow>
        )}
        renderTabToolbarControls={({ tabs, path }) => (
          <>
            {tabs.length < MAX_TABS && <DefaultAddTabButton path={path} />}
            <TabSplitButton path={path} />
            <TabRemoveButton path={path} />
          </>
        )}
        initialValue={{
          type: 'tabs',
          tabs: ['a', 'b'],
          activeTabIndex: 0,
        }}
      />
    
  );
}

Add tabs until you reach four — the + disappears.

Fully custom add button

Swap DefaultAddTabButton for your own element. Call
mosaicActions.addTab(path) to run the library's tab-add logic: it
appends to an existing tab group, or converts a leaf into a 2-tab group
when path points at a leaf. Returns a promise that rejects if
createNode isn't set.

tsx live
function CustomAddButtonExample() {
  let counter = 0;

  function CustomAddButton({ path }) {
    const { mosaicActions } = React.useContext(MosaicContext);
    return (
      <DefaultToolbarButton
        text="New tab"
        onClick={() => {
          if (window.confirm('Open a new tab?')) {
            mosaicActions.addTab(path);
          }
        }}
      >
        ➕ New
      </DefaultToolbarButton>
    );
  }

  return (
    
      <Mosaic
        createNode={() => `tab-${++counter}`}
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`Tab ${id}`}>
            Panel {id}
          </MosaicWindow>
        )}
        renderTabToolbarControls={({ path }) => (
          <>
            <CustomAddButton path={path} />
            <TabSplitButton path={path} />
            <TabRemoveButton path={path} />
          </>
        )}
        initialValue={{
          type: 'tabs',
          tabs: ['a', 'b'],
          activeTabIndex: 0,
        }}
      />
    
  );
}

The same mosaicActions.addTab(path) is available anywhere in your app —
from keyboard shortcut handlers, menu items, command palettes — not just
from inside the renderer.

Escape hatch: `renderTabToolbar`

If none of the slots above fit, renderTabToolbar hands you the entire tab
bar. You receive { tabs, activeTabIndex, path, DraggableTab } and must
return the full toolbar element, including tab rendering, drop targets, and
anything else. This is rarely what you want; reach for it only when the
default layout itself is wrong for your app (e.g. vertical tabs, tabs on the
bottom).