Responsive toolbar API
Default tabs are lists of groups declared with defineToolbarGroup / defineToolbarTab. A layout engine measures the rendered groups and items and, when the tab is too narrow, moves controls into popovers rather than nested menus.
Responsive model
Space is reclaimed along one priority ladder shared by items and groups. Lower numbers give up space first:
- Demote an item. Items with a numeric
overflowmove one by one into their group’s overflow popover, opened by a slim trigger at the group’s edge. - Collapse a group. The group becomes a labelled trigger that opens a panel with the same controls. A group whose only content is a single item renders that item instead of a trigger.
Both steps use the same scale: an item’s overflow and a group’s priority, both defaulting to 0. On a tie, items demote before groups collapse. A group collapses when its own step is reached, even if some of its items haven’t demoted yet, so give a group a higher priority than its items’ overflow values if you want them to demote first. The built-in Home groups use priorities 30 to 50 with item values 1 to 24.
Groups with collapsible: false never collapse, though their items still demote. Anything still too wide scrolls horizontally. Dividers are inserted between groups automatically.
Items with overflow: "always" sit in the overflow popover at every width. Prefer a low numeric priority, so the control stays visible while there is room.
Dropdowns inside panels and popovers are portalled like any other dropdown, so they need no DropdownMenuSub conversion.
Types
ToolbarGroupDef, ToolbarItemDef, ToolbarRenderContext, ToolbarPlacement and ToolbarTabDef are exported from @stesura/editor-react/toolbar. ToolbarOverflowBehavior comes from @stesura/editor-react/types.
type ToolbarPlacement = "ribbon" | "panel";
type ToolbarRenderContext = {
placement: ToolbarPlacement;
size: "small" | "large";
compact: boolean;
};
// "never" (default): stays until the whole group collapses.
// "always": in the group's overflow popover at every width.
// number: demotion priority, on the same scale as group `priority`.
type ToolbarOverflowBehavior = "never" | "always" | number;
type ToolbarItemDef = {
key: string; // unique within the group
size: "small" | "large";
row?: 0 | 1;
separatorBefore?: boolean;
overflow?: ToolbarOverflowBehavior;
Component: React.ComponentType<ToolbarRenderContext>;
};
type ToolbarGroupDef = {
key: string;
label: string;
icon: ReactNode; // shown on the collapsed-group trigger
priority?: number; // default 0; lower collapses first
items: readonly ToolbarItemDef[];
Content?: React.ComponentType<ToolbarRenderContext>; // replaces the item rows
compactInline?: boolean; // Content groups: stay inline in compact density
Panel?: React.ComponentType; // replaces the body inside the collapsed panel
panelNavigation?: "toolbar" | "tab"; // default "tab" with a Panel, else "toolbar"
collapsible?: boolean; // default true
onExpand?: () => void; // group settings launcher
disabled?: boolean; // disables the collapsed trigger and the launcher
minWidth?: string;
maxWidth?: string;
};
type ToolbarTabDef = {
value: string;
label: string;
groups: readonly ToolbarGroupDef[];
isVisible?: (ctx: ToolbarTabContext) => boolean;
focusKey?: (ctx: ToolbarTabContext) => string | null;
};onExpand appears as a launcher in the group’s label row, as an inline settings button in compact density, and as a settings button inside the collapsed panel.
defineToolbarGroup and defineToolbarTab return their argument unchanged; they exist for typing. Define groups at module scope or memoize them: the layout re-measures whenever the group structure (keys, rows, overflow values) changes.
toolbarItem
toolbarItem(key, size, Component, options?: { row?, separatorBefore?, overflow? }): ToolbarItemDefComponent takes no props. toolbarItem wraps it, so it does not receive the render context and has to read its state from the toolbar hooks. size is currently informational: it isn’t passed to the component, and the layout engine works from measured widths. A button’s look comes from the component itself (e.g. EditorButtonLarge vs EditorButton). To react to placement or compact, write the ToolbarItemDef by hand.
Creating a group
Simple items
import { defineToolbarGroup, toolbarItem } from "@stesura/editor-react/toolbar";
import { PageBreakIcon } from "@stesura/editor-react-ui/icons";
import { InsertPageBreakButton } from "./insert-page-break";
export const pageGroup = defineToolbarGroup({
key: "insert-page",
label: "Page",
icon: <PageBreakIcon />,
priority: 50,
items: [toolbarItem("page-break", "large", InsertPageBreakButton)],
});Item-level overflow
Give items a numeric overflow to let the group shed them one by one:
defineToolbarGroup({
key: "my-font",
label: "Font",
icon: <BoldIcon />,
priority: 45, // above the item values, so the items demote first
items: [
toolbarItem("bold", "small", BoldButton, { row: 1 }), // never demotes
toolbarItem("subscript", "small", SubscriptButton, { row: 1, overflow: 1 }), // demotes first
toolbarItem("code", "small", CodeButton, { row: 1, overflow: 10 }),
],
});The overflow popover renders the real components in their two rows. Toggles keep it open. Item-level overflow applies only to item groups; a group with Content collapses whole.
A two-row group is as wide as its wider row, so demoting an item from the narrower row frees no space. Balance priorities across rows.
Custom layout (Content)
When a layout doesn’t map to item rows, such as the style gallery or the image alignment grid, give the group a Content component. It renders the group body only (no Toolbar.Group wrapper) and receives the ToolbarRenderContext. In the collapsed panel it gets placement: "panel" and compact: false.
import { defineToolbarGroup, type ToolbarRenderContext } from "@stesura/editor-react/toolbar";
import { BulletListIcon } from "@stesura/editor-react-ui/icons";
const CalloutGalleryContent = ({ placement }: ToolbarRenderContext) => (
<div className="flex h-full flex-col justify-center gap-1">
{/* gallery; denser when placement === "panel" */}
</div>
);
export const calloutGroup = defineToolbarGroup({
key: "callout-gallery",
label: "Callouts",
icon: <BulletListIcon />,
priority: 40,
items: [],
Content: CalloutGalleryContent,
});Custom panel (Panel)
Panel replaces the group’s body inside the collapsed panel. It takes no props. Groups with inputs or grids (image size, table spacing, the style gallery) use it together with panelNavigation: "tab", which is the default when Panel is set:
defineToolbarGroup({
key: "callout-size",
label: "Size",
icon: <MoveHorizontalIcon />,
priority: 10,
items: [],
Content: CalloutSizeContent,
Panel: CalloutSizePanel,
panelNavigation: "tab",
});Placement and panel navigation
| Concept | Meaning |
|---|---|
placement: "ribbon" | Rendered in the ribbon |
placement: "panel" | Rendered inside a collapsed group’s panel |
panelNavigation: "toolbar" | Arrow keys move between controls. Default without Panel. |
panelNavigation: "tab" | Normal Tab order, for inputs, grids and forms. Default with Panel. |
List-shaped menus (ToolbarMenuContent)
For a dropdown that is a list of commands, describe it as data and let ToolbarMenuContent render the DropdownMenuContent and any submenus (kind: "submenu"):
import { EditorSplitButton, ToolbarMenuContent } from "@stesura/editor-react-ui";
import { AlignLeftIcon } from "@stesura/editor-react-ui/icons";
<EditorSplitButton tooltip="Align" moreOptionsLabel="More alignment options" icon={<AlignLeftIcon />}>
<ToolbarMenuContent
menu={{
kind: "items",
items: [
{ kind: "action", id: "left", label: "Left", onSelect: () => onAlign("left") },
{ kind: "separator", id: "s1" },
{ kind: "checkbox", id: "dist", label: "Distribute", checked, onCheckedChange: toggle },
],
}}
/>
</EditorSplitButton>For custom bodies (colour pickers, galleries), use { kind: "custom", Body }. Body renders inside the menu surface and receives { depth }; don’t render your own DropdownMenuContent in it.
Extensions
Element extensions (not collapsible)
toolbarDefaultExtensions: [{ tabKey: "home", element: <MyButton /> }]Elements render after the tab’s groups, each behind a divider, and never collapse. The layout measures them and reserves their width, so the groups still demote and collapse around them. If an element stacks two rows of small buttons beside a large one (like the footnotes group), mark the stack with data-toolbar-stack so compact density flattens it to one row.
Group extensions (collapsible)
import { defineToolbarGroup, toolbarItem } from "@stesura/editor-react/toolbar";
import type { StesuraUIExtension } from "@stesura/editor-react/types";
import { SparkleIcon } from "@stesura/editor-react-ui/icons";
export const aiToolbarExtension: StesuraUIExtension = {
toolbarGroupExtensions: [
{
tabKey: "home",
after: "home-font",
group: defineToolbarGroup({
key: "ai",
label: "AI",
icon: <SparkleIcon />,
priority: 20,
items: [toolbarItem("rewrite", "large", RewriteButton)],
}),
},
],
};mergeToolbarGroups inserts each group after the group keyed after, in array order. It appends the group when after is missing or matches no group. Built-in group keys:
| Tab | Group keys |
|---|---|
home | home-editing, home-font, home-paragraph, home-search, home-styling |
insert | insert-page, insert-table, insert-symbols, insert-math, insert-images, insert-file, insert-code, insert-links |
view | view-show, view-zoom |
page | page-section |
headerFooter | header-footer-position, header-footer-page-number, header-footer-close |
table | table-styling, table-spacing, table-row-column, table-merge, table-cell-align, table-align, table-delete, table-column-width, table-row-height, table-data |
references | references-toc |
review | review-accessibility |
image | image-insert, image-align, image-wrap, image-size |
export | export-json |
toolbarGroupExtensions is read only from uiExtensions; neither ToolBar nor useDefaultToolbarTabs takes it as a separate option.
Compact density
The full ribbon is 102px tall, with labelled groups and large buttons. A toggle at the bottom-right switches to compact density, which:
- merges each item group into one short row (the overflow and panel popovers keep the two rows);
- renders
Contentgroups as panel triggers, unless they setcompactInlineorcollapsible: false(acompactInlinegroup’sContentreceivescompact: true); - hides the group labels, so
onExpandmoves to an inline settings button; - shrinks
EditorButtonLargeicons to 16px and hides the horizontal scrollbar.
Compact is the default on phone-sized viewports (narrower than 768px) and follows the viewport until the user toggles it. Collapsing on narrow widths happens at either density.
Custom tabs
A custom tab whose content is plain JSX doesn’t collapse; it scrolls horizontally. To get the behaviour above in your own tab, render a defineToolbarTab definition with ToolbarTabRenderer. See Responsive custom tabs.
Reference implementations
Paths are relative to packages/editor-react/src/toolbar/ unless noted.
| Pattern | File |
|---|---|
| Single-button groups | insert/insert-tab-def.tsx |
| Item overflow across two rows | home/font/font-group.tsx, home/paragraph/paragraph-group.tsx |
| Non-collapsible group (Undo always visible) | home/base/base-group.tsx, used in home/home-tab-def.tsx |
Style gallery: Content + Panel + compactInline | home/styling-group/styling-group.tsx |
Content + Panel with tab navigation | image/image-tab-def.tsx (Size group) |
data-toolbar-stack element extension | packages/references-react/src/toolbar/reference/footnotes-group/footnotes-group.tsx |
Exports from @stesura/editor-react/toolbar
defineToolbarGroup,defineToolbarTab,mergeToolbarGroups,toolbarItemToolbarTabRenderer,useMergedToolbarTab,useDefaultToolbarTabs,useToolbarTabContextdefineToolbarProbes,useCanRunTb(Enabled state) anddefineToolbarSelectors,useTbSelector,memoizeByState,shallowEqual(Display state), also on the package rootallowInViewMode,isAllowedInViewMode- The built-in probes and selector sets
- Types:
ToolbarGroupDef,ToolbarItemDef,ToolbarTabDef,ToolbarRenderContext,ToolbarPlacement,ToolbarGroupExtension,ToolbarTabComponentProps,TbProbe,TbSelector,TbSelectorFn
Toolbar.Group, Toolbar.Divider, Toolbar.Content, EditorButton, EditorButtonLarge and EditorSplitButton come from @stesura/editor-react-ui, for custom tab content and element extensions.