Skip to Content

UI Extensions

A StesuraUIExtension bundles the UI a feature adds to the editor: toolbar items, panels, menu entries, node views, status-bar entries and overlays. Pass extensions to StesuraEditor through the uiExtensions prop. It is the only way to add this UI: StesuraEditor has no separate props for panels, menus or status-bar entries.

Shape

All fields are optional (source: packages/editor-react/src/types/core.ts):

type StesuraUIExtension = { // Panels leftPanels?: LeftPanelElement[]; rightPanels?: RightPanelElement[]; bottomPanels?: BottomPanelElement[]; // Menus contextMenuExtensions?: ContextMenuExtension[]; floatingMenuExtensions?: FloatingMenuExtension[]; textMenuExtensions?: TextMenuExtension[]; slashMenuExtensions?: SlashMenuExtension[]; // Toolbar toolbarDefaultTabs?: ToolbarDefaultTabsDefaults; // hide built-in tabs toolbarDefaultExtensions?: ToolbarDefaultExtension[]; // append elements to built-in tabs toolbarGroupExtensions?: ToolbarGroupExtension[]; // insert responsive groups // Node views nodeViews?: NodeViews; nodeViewWrappers?: Record<string, ComponentType<NodeViewWrapperComponentProps>>; // Status bar and slots StatusBarElements?: StatusBarElement[]; documentOverlaySlot?: ReactNode; ResizableLayoutBottomSlot?: ReactNode; CommentsSlot?: ReactNode; TopLeftSlot?: ReactNode; // declared, but not rendered (see below) // PDF file previews (@stesura/pdf-viewer-react) pdfPreview?: PdfPreviewCapability; };

Pass the extensions as a stable array. A new array identity re-merges every field and re-creates all node views.

import { StesuraEditor } from "@stesura/editor-react"; import { paginationUiExtension } from "@stesura/pagination-react"; import { trackChangesUIExtension } from "@stesura/track-changes-react"; import { referenceUiExtensions } from "@stesura/references-react"; // Module scope, or useMemo. const uiExtensions = [paginationUiExtension, trackChangesUIExtension, referenceUiExtensions]; <StesuraEditor /* state, schema, ... */ uiExtensions={uiExtensions} />;

How extensions merge

  • Keyed entries are resolved in array order, and the later entry wins. This covers panels (by action), floating menus (by key), node views (by node type), and menu entries that reuse a built-in key or id.
  • Everything else is concatenated in array order: toolbar elements and groups, status-bar entries, slots, and menu entries with new keys. Two extensions that add the same new menu key both render.
  • toolbarDefaultTabs maps are combined. Since false is the only legal value, any extension can hide a tab and none can bring it back.
  • pdfPreview: the first extension that declares it wins.

Writing your own extension

import type { StesuraUIExtension } from "@stesura/editor-react/types"; const wordCountExtension: StesuraUIExtension = { bottomPanels: [{ action: "word-count", title: "Word count", content: <WordCountPanel /> }], }; const uiExtensions = [wordCountExtension]; <StesuraEditor /* ... */ uiExtensions={uiExtensions} />;

Everything except the toolbar renders inside the editor’s ProseMirror context, so hooks such as useEditorState and useEditorEventCallback from @handlewithcare/react-prosemirror work there. The toolbar sits outside it: toolbar elements read the editor through the bound-editor hooks (useBoundEditorState, useBoundEditorViewCallback, …).


Toolbar items

These fields only affect the built-in toolbar. If you pass your own toolbar (or toolbar={false}), they are ignored.

Adding content to a built-in tab

toolbarDefaultExtensions appends a React node to a built-in tab:

const myExtension: StesuraUIExtension = { toolbarDefaultExtensions: [ // tabKey: "home" | "insert" | "view" | "page" | "headerFooter" | "table" // | "references" | "review" | "image" | "export" { tabKey: "insert", element: <MyInsertButton /> }, ], };

These elements render after the tab’s groups and never collapse. For a group that collapses responsively, use toolbarGroupExtensions: { tabKey, group, after? }, where group comes from defineToolbarGroup (@stesura/editor-react/toolbar) and after is the key of the group to insert after. See Create new items.

Hiding a built-in tab

const myExtension: StesuraUIExtension = { toolbarDefaultTabs: { export: false, // hides the Export tab }, };

Panels

There are three panel areas: left, right and bottom. Each shows one panel at a time, chosen by its action.

type LeftPanelElement = { action: string; content: ReactNode }; type RightPanelElement = { action: string; content: ReactNode }; type BottomPanelElement = { action: string; content: ReactNode; title?: string; // label of the collapsed expander button; defaults to "Bottom panel" };

Open and close panels with the hooks from @stesura/editor-react:

import { useLeftPanelActions } from "@stesura/editor-react"; const OpenOutlineButton = () => { const { openLeftPanel } = useLeftPanelActions(); return <button onClick={() => openLeftPanel("my-panel")}>Outline</button>; };
AreaActions only (no re-render)StateBoth
LeftuseLeftPanelActions()useLeftPanelState()useLeftPanelControls()
RightuseRightPanelActions()useRightPanelState()useRightPanelControls()
BottomuseBottomPanelActions()useBottomPanelState()useBottomPanelControls()

The actions are open*Panel(action), close*Panel(), toggle*Panel(action) and set*PanelAction(action). openRightPanel(action, meta?) also passes a payload to the panel. Right-panel actions are async and respect a guard a panel can register (the stylesheet panel uses it to confirm discarding edits).

Built-in actions:

  • Left: "toc" (navigation).
  • Right: "format paragraph", "style sheet", "toc settings", "accessibility".
  • From feature packs: "proofread" (proofreadUIExtension), "crossReference" (referenceUiExtensions), "aiChat" (createAiUIExtension with chatApi).

You can replace a built-in panel by registering the same action, but you cannot remove one. When any bottom panel is registered, an expander button appears at the bottom right of the document area. In the compact (phone) layout, left and right panels open as drawers and bottom panels are not rendered.


FieldAddsBuilt-in keys
contextMenuExtensionsItems in the right-click menu: { key, element, order?, appliesTo? }base, link, paragraph, image, table, toc, codeBlock
floatingMenuExtensionsMenus that follow the selection: { key, element, closeOnOpen? }textFormat, link, slash, math
textMenuExtensionsButtons in the text-format popup: { key, element }bold, italic, underline, highlight, formatPainter, fontSizeIncrease, fontSizeDecrease, fontColor, backgroundColor
slashMenuExtensionsSlash-menu items, as data rather than elementsparagraph, heading1heading3, bulletList, orderedList, codeBlock, horizontalRule, table

Reusing a built-in key (or slash-menu id) replaces that entry in place. New text-menu buttons are appended after the built-ins. See Customizing the Menus for ordering, controllers and how to hide built-ins.


Node views

const calloutExtension: StesuraUIExtension = { nodeViews: { callout: CalloutNodeView }, };

Resolution order, later wins: built-in views, then extension nodeViews in array order, then the nodeViews prop on StesuraEditor. A prop entry only wins when it is a custom view: passing a built-in view back in keeps the extension’s override. See Node Views.

nodeViewWrappers wraps whichever view wins for a node type. The wrapper receives the node-view props (without ref), must render {children} (the wrapped view) and must not add DOM. It contributes through context. Unlike a nodeViews entry, a wrapper survives the consumer’s overrides. Pagination uses it for page breaks inside table cells and rows.


Slots

Slot contents from all extensions are rendered, in array order.

FieldWhere it renders
documentOverlaySlotAlways mounted in the main editor column (around the document area and bottom panel), outside the scroll area. Position content yourself or portal it. Used for the header/footer editor, the footnote and endnote panels, and the track-changes tooltip.
ResizableLayoutBottomSlotDirectly below the document scroll area, above the bottom panel.
CommentsSlotNext to the document, inside the scroll area. The canvas only reserves room for it when comment threads are visible, so it is meant for commentsUiExtension.
TopLeftSlotNot rendered. The field is declared, but the editor never reads it from extensions.

Status bar

StatusBarElements entries are { element, position: "left" | "right" }. Left entries follow the built-in language, statistics and units controls. Right entries sit before the mode toggle and zoom controls. Entries are not rendered in the compact (phone) layout.

The bar is a role="region" with one Tab stop per widget. It is not a toolbar: there is no roving tabindex, and nothing manages your element’s focus. Your element must carry its own semantics:

  • Name it for what it does. The visible text is usually the current state (“Pagination on”). Use "<state> — <what it opens>" as the accessible name, keeping the visible text inside it (WCAG 2.5.3).
  • Show a focus ring. A bare <button> has none. Render triggers as Button variant="ghost", like the built-in entries.
  • Meet the 24×24px target minimum (WCAG 2.5.8). Button size="icon-xs" is 24px.
  • Mark decorative icons aria-hidden and pair them with visible text. Color alone cannot carry state.
  • Don’t add a divider. The bar inserts aria-hidden dividers between entries.
import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@stesura/editor-react-ui"; const WordGoal = () => ( <DropdownMenu> <DropdownMenuTrigger render={<Button variant="ghost" size="sm" className="h-6 gap-1.5 px-2 text-xs" />} aria-label="Goal: 1,200 words — change word goal" > Goal: 1,200 words </DropdownMenuTrigger> <DropdownMenuContent align="start" sideOffset={10}> <DropdownMenuItem>Change goal…</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); const myExtension: StesuraUIExtension = { StatusBarElements: [{ element: <WordGoal />, position: "right" }], };

Reference implementations: ProofreadToggleStatusBar (exported from @stesura/proofread-react) and the pagination toggle (packages/pagination-react/src/status-bar, not exported).

Escape anywhere in the bar returns focus to the document. Menus and dialogs portal to document.body, so their own Escape handling is unaffected.


Feature-pack extensions

ExportPackage
paginationUiExtension@stesura/pagination-react
trackChangesUIExtension@stesura/track-changes-react
referenceUiExtensions@stesura/references-react
commentsUiExtension@stesura/comments-react
proofreadUIExtension@stesura/proofread-react
docxUIExtension@stesura/docx-react
pdfUiExtension@stesura/pdf-export-client
createPdfViewer(options)@stesura/pdf-viewer-react
createAiUIExtension(options)@stesura/ai-react

The last two are factories: call them once (module scope or useMemo) so the array stays stable.

Next steps

Last updated on