Skip to main content

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:

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.

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

  const toolbar = (
    <div className="mosaic-window-controls" style={{ color: buttonColor }}>
      <button
        className="mosaic-default-control bp5-button bp5-minimal"
        style={{ color: buttonColor }}
        onClick={() => alert('custom action!')}
      >
        Custom
      </button>
      <Separator />
      <ExpandButton />
      <RemoveButton />
    </div>
  );

  return (
    <div className="live-mosaic-frame">
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow
            path={path}
            title={`Panel ${id}`}
            toolbarControls={toolbar}
          >
            <div style={{ padding: 20, fontFamily: 'sans-serif' }}>
              Edit <code>buttonColor</code> above to re-theme the toolbar.
            </div>
          </MosaicWindow>
        )}
        initialValue={{
          type: 'split',
          direction: 'row',
          children: ['left', 'right'],
        }}
      />
    </div>
  );
}
Result
Loading...

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:

Live Editor
function CustomButtonExample() {
  function StarButton() {
    return (
      <DefaultToolbarButton
        title="Star this panel"
        className="custom-star-button"
        onClick={() => alert('starred!')}
      >

      </DefaultToolbarButton>
    );
  }

  const toolbar = (
    <div className="mosaic-window-controls">
      <StarButton />
      <Separator />
      <ExpandButton />
      <RemoveButton />
    </div>
  );

  return (
    <div className="live-mosaic-frame">
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow
            path={path}
            title={`Panel ${id}`}
            toolbarControls={toolbar}
          >
            <div style={{ padding: 20 }}>Panel {id}</div>
          </MosaicWindow>
        )}
        initialValue={{ type: 'split', direction: 'row', children: ['a', 'b'] }}
      />
    </div>
  );
}
Result
Loading...

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:

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:

PropWhat it swapsLibrary keeps owning
renderTabTitleContent inside each tab buttonDrag, close, DnD
renderTabToolbarControlsThe right-side controls cluster (add, split, remove, …)Drag handle, drop targets
renderTabToolbarThe 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:

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.

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

  return (
    <div className="live-mosaic-frame">
      <Mosaic
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`${id}`}>
            <div style={{ padding: 20, fontFamily: 'sans-serif' }}>
              Open: <strong>{id}</strong>
              {isMarkdown(id) && <div>(Preview available)</div>}
            </div>
          </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,
        }}
      />
    </div>
  );
}
Result
Loading...

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.

Live Editor
function TabLimitExample() {
  const MAX_TABS = 4;
  let counter = 0;

  return (
    <div className="live-mosaic-frame">
      <Mosaic
        createNode={() => `tab-${++counter}`}
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`Tab ${id}`}>
            <div style={{ padding: 20 }}>Panel {id}</div>
          </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,
        }}
      />
    </div>
  );
}
Result
Loading...

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.

Live Editor
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 (
    <div className="live-mosaic-frame">
      <Mosaic
        createNode={() => `tab-${++counter}`}
        renderTile={(id, path) => (
          <MosaicWindow path={path} title={`Tab ${id}`}>
            <div style={{ padding: 20 }}>Panel {id}</div>
          </MosaicWindow>
        )}
        renderTabToolbarControls={({ path }) => (
          <>
            <CustomAddButton path={path} />
            <TabSplitButton path={path} />
            <TabRemoveButton path={path} />
          </>
        )}
        initialValue={{
          type: 'tabs',
          tabs: ['a', 'b'],
          activeTabIndex: 0,
        }}
      />
    </div>
  );
}
Result
Loading...

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).