Composition
Build your own DOCX editor UI on unstyled primitives: provider, viewport, content, compound parts, asChild, slot overrides, and the customization ladder.
<DocxEditor> is one arrangement of parts that are all public. When the packaged chrome is not what you want, take the parts.
The packaged toolbar is built from the hooks and primitives on this page, so a control you write has the same capabilities as one that ships in the box.
The Igloo example is a fully
re-skinned editor: every control is host markup over the hooks. Every pattern below appears in it,
running. bun run dev:igloo.
Primitives
Every editor is these three components, in this order:
import { DocxEditor } from '@docx-editor.dev/react';
export function Editor({ bytes }: { bytes: Uint8Array }) {
return (
<DocxEditor.Root document={bytes}>
<DocxEditor.Viewport>
<DocxEditor.Content />
</DocxEditor.Viewport>
</DocxEditor.Root>
);
}Rootowns the editor instance and publishes it on context. It renders no DOM of its own. Document-level props live here:document,mode,author,fonts,locale,modules,onReady,onChange.Viewportis the scroll container, and carries the layout classes the engine positions pages against.Contentis the mount point the engine paints pages into. The painted pages are the editable surface.
Everything else (toolbar, menu, rulers, navigation pane, link popover, context menu) is optional and can be placed anywhere inside Root.
Customization ladder
Start at the top and go down only as far as you need:
- CSS and tokens. Restate the
--doc-*palette under your own scope. iconprop. Swap a glyph without touching behavior.asChild. Merge the wiring onto your own element.- Slot override. Replace one part of a compound in place; keep the rest.
preset={false}. Opt out of the default arrangement and order it yourself.- Hooks. Write the markup yourself. See Hooks.
asChild
asChild hands the part's behavior (click handler, disabled state, ARIA, active state) to the child you supply and renders no wrapper:
import { DocxEditor } from '@docx-editor.dev/react';
import { Button } from '@/components/ui/button';
<DocxEditor.Toolbar.Bold asChild>
<Button variant="ghost">Bold</Button>
</DocxEditor.Toolbar.Bold>;Slot override
A part child replaces the slot of the same name and leaves the rest of the default arrangement alone. hidden removes a slot:
// The default toolbar, but with our own bold button and no highlight control.
<DocxEditor.Toolbar>
<DocxEditor.Toolbar.Bold className="my-bold" />
<DocxEditor.Toolbar.Highlight hidden />
</DocxEditor.Toolbar>Custom toolbar
preset={false} opts out of the registry's default arrangement, so the order in your JSX is the order on screen. Every packaged part still drives its chrome slot: enabled state, pressed state, and the command all still come from the engine. Only the glyph is yours.
<DocxEditor.Toolbar preset={false} className="my-toolbar">
<DocxEditor.Toolbar.Undo icon={MyUndo} />
<DocxEditor.Toolbar.Redo icon={MyRedo} />
<DocxEditor.Toolbar.Separator />
{/* Compound pickers kept whole. A host that wants a different look should not have to
rebuild the picker's behavior. These are the packaged components, restyled. */}
<DocxEditor.Toolbar.StylePicker className="my-picker" />
<DocxEditor.Toolbar.FontFamily className="my-picker" />
<DocxEditor.Toolbar.FontSize />
<DocxEditor.Toolbar.Separator />
<DocxEditor.Toolbar.Bold icon={MyBold} />
<DocxEditor.Toolbar.Italic icon={MyItalic} />
<DocxEditor.Toolbar.FontColor icon={MyFontColor} />
<DocxEditor.Toolbar.Separator />
<div className="my-toolbar__spacer" />
<DocxEditor.Toolbar.Zoom />
</DocxEditor.Toolbar>Every part name is a ChromeSlotId, so a slot the registry gains later shows up in the default arrangement without you editing anything. Ordering by hand trades that away deliberately.
icon takes an element, not a component:
// Right.
export const MyBold = (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M7 5h6a4 4 0 0 1 0 8H7z" />
</svg>
);
<DocxEditor.Toolbar.Bold icon={MyBold} />;
// Wrong: `() => <svg />` is a component, and the prop is a ReactNode.A host action that asks the engine
Toolbar.Action is for an action the registry has no slot for. The label, glyph, and effect are yours. What should not be yours is whether it may run: ask useEditorCommand about the command onSelect will exec, so the button cannot offer something the engine is about to refuse, and the tooltip carries the engine's reason rather than a guess.
import { useDocxEditor, useEditorCommand, useEditorState } from '@docx-editor.dev/react';
import type { EditorCommand } from '@docx-editor.dev/react';
const highlight = (value: string): EditorCommand => ({
type: 'setMarkAttr',
mark: 'highlight',
attr: 'val',
value,
});
function useHighlightAction() {
const editor = useDocxEditor();
const { isEnabled, disabledReason } = useEditorCommand(highlight('cyan'));
// Two gates, two questions. The engine says yes at a collapsed caret, correctly: that
// arms the typing format the way Word's Bold does. It paints nothing, though, so a
// button that stays live there is one the user presses and sees nothing happen.
const collapsed = useEditorState((s) => s.selectionCollapsed);
return {
apply: () => editor?.exec(highlight('cyan')),
enabled: isEnabled && !collapsed,
disabledReason: collapsed && isEnabled ? 'nothing is selected' : disabledReason,
};
}
function HighlightAction() {
const { apply, enabled, disabledReason } = useHighlightAction();
return (
<DocxEditor.Toolbar.Action
label="Highlight"
icon={MyHighlight}
disabled={!enabled}
{...(disabledReason ? { disabledReason } : {})}
onSelect={apply}
/>
);
}Put the hook in one file and share it across every surface that exposes the action. Two copies drift into disagreeing about when the action is available, which is the failure the chrome registry avoids for packaged controls by deriving the toolbar and the menu from one table.
Custom context menu
Every composition mode in one panel: packaged rows kept, a packaged row re-iconed, a packaged row removed, a chrome slot pulled in as a row, host rows, and a submenu.
{
/* `enabled` and `apply` come from useHighlightAction() above; `editor` from useDocxEditor(). */
}
<DocxEditor.ContextMenu className="my-menu">
{/* Packaged rows, re-iconed. They still run the engine's commands and still carry the
engine's reason when nothing is selected. */}
<DocxEditor.ContextMenu.Cut icon={MyCut} />
<DocxEditor.ContextMenu.Copy icon={MyCopy} />
{/* Drop a packaged row. */}
<DocxEditor.ContextMenu.Slot slot="review.comments" hidden />
{/* Your own row. No slot, no command: you supply the label, the enabled state, the action. */}
<DocxEditor.ContextMenu.Item
label="Highlight"
icon={MyHighlight}
disabled={!enabled}
onSelect={apply}
/>
<DocxEditor.ContextMenu.Submenu labelKey="my.insert" paths={null}>
<DocxEditor.ContextMenu.Item
label="Page break"
onSelect={() => editor?.exec({ type: 'insertBreak', kind: 'page' })}
/>
<DocxEditor.ContextMenu.Item
label="3×3 table"
onSelect={() => editor?.exec({ type: 'insertTable', rows: 3, cols: 3 })}
/>
</DocxEditor.ContextMenu.Submenu>
{/* A chrome slot as a row: label, icon and enabled state all from the registry, so it
cannot disagree with its toolbar twin. */}
<DocxEditor.ContextMenu.Slot slot="format.clear" />
</DocxEditor.ContextMenu>;A child the compound does not recognize is appended rather than wrapping the rows, so decorative markup lands after them in DOM order and the library keeps its own panel element, roles, keyboard handling, and placement.
Custom menu bar
The default bar derives from CHROME_MENUS, so it is already correct without you writing anything. What a product adds on top is usually a menu of its own and a replacement for Help:
{
/* `enabled` and `apply` come from useHighlightAction() above; `editor` from useDocxEditor(). */
}
<DocxEditor.Menu className="my-menubar">
{/* The registry's menus, re-iconed in place. Each still derives its rows from the
registry, so a row added upstream still appears here. */}
<DocxEditor.Menu.File icon={MyFile} />
<DocxEditor.Menu.Format icon={MyFormat} />
<DocxEditor.Menu.Insert icon={MyInsert} />
{/* A menu the library has never heard of. `MenuId` accepts any string. `label` rather
than `labelKey`, because its name will never be in our catalog. */}
<DocxEditor.Menu.Menu id="review" label="Review" icon={MyReview} preset={false}>
<DocxEditor.Menu.Row disabled={!enabled} onSelect={apply}>
Highlight passage
</DocxEditor.Menu.Row>
<DocxEditor.Menu.Separator />
<DocxEditor.Menu.Row
onSelect={() => editor?.exec({ type: 'insertBreak', kind: 'page' })}
shortcut="Ctrl+Enter"
>
Page break
</DocxEditor.Menu.Row>
</DocxEditor.Menu.Menu>
{/* Help, replaced: the packaged Report-issue row opens this project's tracker, which is
the wrong destination for a product that merely embeds the editor. */}
<DocxEditor.Menu.Help icon={MyHelp} preset={false}>
<DocxEditor.Menu.Row onSelect={() => window.open('/docs', '_blank', 'noopener')}>
Documentation
</DocxEditor.Menu.Row>
</DocxEditor.Menu.Help>
</DocxEditor.Menu>;Custom navigation pane
The pane's parts are statics, so you can hang your own class on each one instead of styling the library's internals. The parts still do the work: the headings list is still fed by the engine's outline.
<DocxEditor.Navigation className="my-nav" toggle={{ className: 'my-nav__toggle' }}>
<DocxEditor.Navigation.Header className="my-nav__header">
<DocxEditor.Navigation.Close className="my-nav__close" />
<DocxEditor.Navigation.Title className="my-nav__title" />
</DocxEditor.Navigation.Header>
<DocxEditor.Navigation.Tabs className="my-nav__tabs" />
<DocxEditor.Navigation.Headings className="my-nav__headings" />
<DocxEditor.Navigation.Find className="my-nav__find" />
</DocxEditor.Navigation>Custom loading screen
DocxEditor.Loading renders only while there is no document:
<DocxEditor.Viewport>
<DocxEditor.Loading>
<div className="my-loading">
<MySpinner />
<span>Opening…</span>
</div>
</DocxEditor.Loading>
<DocxEditor.Content />
</DocxEditor.Viewport>To keep the packaged spinner and restyle only it, DocxEditor.Loading.Spinner takes a className.
Custom link popover
DocxEditor.HyperLink is the popover that opens on a link: the URL, edit fields, apply, copy, unlink. Its parts follow the same contract as every other compound (className, asChild, hidden; action parts take icon), so restyling it is the same work as restyling the toolbar:
<DocxEditor.HyperLink className="my-popover">
<DocxEditor.HyperLink.Copy hidden />
<DocxEditor.HyperLink.Unlink icon={MyUnlink} />
</DocxEditor.HyperLink>The parts are Url, Fields, Edit, Apply, Cancel, Copy, Unlink, and Error. For a popover that shares no markup with the packaged one, useHyperlinkPopup() is the state behind it.
Rulers
DocxEditor.HorizontalRuler and DocxEditor.VerticalRuler read page setup and zoom from the snapshot, so they stay correct across section changes and zoom. Dragging a margin previews live and commits once on release: one transaction, one undo entry. Against a read-only document the handles are inert.
<DocxEditor.Root document={bytes}>
<DocxEditor.HorizontalRuler />
<DocxEditor.Viewport>
{/* Absolutely positioned at the editing area's left edge, so it scrolls with the pages. */}
<DocxEditor.VerticalRuler />
<DocxEditor.Content />
</DocxEditor.Viewport>
</DocxEditor.Root>Page setup dialog
DocxEditor.PageSetupDialog is controlled: you own open and it reports onClose. The packaged menu wires it to Format → Page setup; a composed host wires it to its own trigger:
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Page setup…</button>
<DocxEditor.PageSetupDialog open={open} onClose={() => setOpen(false)} />It reads and writes through the same engine command as usePageSetup(), so a dialog of your own markup is the hook plus a form.
Content-control panel
DocxEditor.ContentControl is the inspector for the control at the caret, with Header, Fields, and Remove parts that compose like every other compound. What the panel reports (locks, data bindings, fill-only mode) is covered in Content controls.
Page furniture
The smaller parts place individually; a part you do not mount has no UI:
DocxEditor.PageNumber: the "page n of m" indicator that appears while the document scrolls and fades after.DocxEditor.FontNotice: a dismissible notice listing the fonts the document asked for that were substituted.DocxEditor.DocumentOutline: the headings list on its own, outside the navigation pane. Clicking a heading moves the caret and scrolls it into view.DocxEditor.HeaderFooterChrome: the header/footer editing overlay: region label, inheritance warning, field inserts, and the options menu.DocxEditor.NotesChrome: footnote and endnote chrome: hover preview, context menu, and the numbering properties dialog.
Custom labels
Composed chrome resolves its labels through the active locale catalog on its own: a bare <DocxEditor.Toolbar /> renders real labels with no t handed to it, localized by LocaleProvider like everything else. The same goes for Menu, ContextMenu, and the rest.
Pass a t only to rename labels. useChromeTranslate(overrides?) returns the catalog-backed resolver every part's t prop accepts, with your overrides consulted first:
import { DocxEditor, useChromeTranslate } from '@docx-editor.dev/react';
// Module level: the resolver is memoized on the Map's identity, so an inline
// `new Map(...)` would re-create it, and every consuming part's props, per render.
// A Map, not an object literal: the key is caller input, and an object answers
// `constructor` and `toString` off the prototype chain.
const OVERRIDES = new Map([
['contextMenu.cut', 'Cut text'],
['toolbar.bold', 'Heavy'],
]);
function MyToolbar() {
const t = useChromeTranslate(OVERRIDES);
return <DocxEditor.Toolbar t={t} />;
}Keys you do not override fall through to the catalog, so renaming two labels does not mean restating the other four hundred.
Custom colors
The library's chrome is built on the --doc-* palette, so restating that palette under one scope re-themes the toolbar, menu bar, panels, pickers, rulers, and navigation pane at once. Custom properties inherit, so the scope is the override.
.my-nav {
--doc-surface: transparent;
--doc-text: #fff;
--doc-border: rgba(255, 255, 255, 0.25);
}Two rules worth keeping:
- Do not style
docx-*classes and do not use!important. Those names are implementation details. Every visual change should go through a prop, a--doc-*token, or an element you own. Where that was not possible the library grew a prop instead: trigger icons, color-split icons, and the page's centering margin all came out of building the Igloo demo. - The document canvas is not themed, on purpose. Painter output stays Word-faithful. A page rendered in your brand colors would be a lie about what the file contains, so the theme lives in the chrome around it.
Custom tracked changes and comments
The review surface comes from @docx-editor.dev/pro and composes the same way as everything above. DocxEditorReview renders one card per pending decision beside the page; its props decide which decisions appear and how they stack, its parts decide what a card looks like.
import { DocxEditorReview } from '@docx-editor.dev/pro/react';
<DocxEditorReview
className="my-review"
// Comments in this rail, revisions somewhere else.
filter={(item) => item.kind === 'comment'}
// Structural and formatting cards are off by default: a heavily revised document mints
// one per site and they crowd out the decisions a reviewer reads in order. They stay
// marked in the page, where clicking one opens its balloon.
structural={false}
formatting={false}
stack
gap={12}
furniture={<MyReviewFilters />}
/>;furniture is host content above the cards, for filters or a legend. Place the compound inside the viewport so it scrolls with the document. The suggesting-mode toggle, bulk accept, and the useReview() queue behind all of this are covered in Tracked changes.
Custom cards
Each card is a compound. Reorder, hide, and re-icon its parts in place:
<DocxEditorReview className="my-review">
<DocxEditorReview.Card className="my-card">
<DocxEditorReview.Avatar className="my-avatar" />
<DocxEditorReview.Author />
<DocxEditorReview.Time hidden />
<DocxEditorReview.Summary />
<DocxEditorReview.Accept icon={MyCheck} />
<DocxEditorReview.Reject icon={MyCross} />
<DocxEditorReview.Replies />
<DocxEditorReview.Reply />
</DocxEditorReview.Card>
<DocxEditorReview.Empty>Nothing to review</DocxEditorReview.Empty>
</DocxEditorReview>Every part takes className, asChild, and hidden; the action parts also take icon.
For cards that share nothing with the packaged layout, preset={false} mounts the rail and its context without the packaged arrangement, so you keep the subscription and the anchoring and render the cards yourself. useStackedReviewPositions(items, heights, { gap, scale: editor.getRenderScale() }) provides the packaged stacking math. See Tracked changes for the scale and placement details. Below that, useReview() gives you the queue with no chrome at all.
To add host content to some cards and not others, useReviewItem() reads the card a child is rendered inside:
import { DocxEditorReview, useReviewItem } from '@docx-editor.dev/pro/react';
function OpenSource() {
const item = useReviewItem();
// Cards contributed by a custom node's `reviewCard` hook.
if (item?.kind !== 'custom') return null;
return <button onClick={() => openSource(item)}>Open source</button>;
}
<DocxEditorReview>
<OpenSource />
</DocxEditorReview>;Custom nodes
CustomNodeChrome paints the chips for your custom nodes and dispatches interaction. The chip's tint comes from the definition's chrome.color; the click and hover handlers are where host UI state belongs.
import { CustomNodeChrome, CustomNodeContextMenu } from '@docx-editor.dev/pro/react';
<DocxEditor.Viewport>
<DocxEditor.Content />
<CustomNodeChrome
// Defaults to the definitions registered on the editor. Pass a subset to
// paint only some of them.
nodes={[Citation]}
onNodeClick={(node) => setPopover({ at: node.rect, attrs: node.attrs })}
onNodeHover={(node) => prefetch(node.attrs['sourceId'])}
/>
<DocxEditor.ContextMenu>
{/* Adds an "Edit {label}" row above the packaged rows when the right-click lands on a
chip. Chips are content-locked by default, so this is the editing entry point. */}
<CustomNodeContextMenu onEditNode={(node) => openEditForm(node)} />
</DocxEditor.ContextMenu>
</DocxEditor.Viewport>;An activated node carries name, attrs, tag, and rect (viewport-relative, for anchoring your own popover), plus nodeId and text when they resolve. useCustomNodeDefinitions() reads the definitions registered on the editor if you are building chrome of your own.
Both compounds need their module registered on Root. See Pro.
A full composition
The parts a composed tree usually wants, placed by hand:
export function Workspace({ bytes }: { bytes: Uint8Array }) {
return (
<DocxEditor.Root document={bytes} author="Jess Lin">
<MyBrandHeader />
<MyMenu />
<MyToolbar />
<DocxEditor.HorizontalRuler />
<div className="workspace">
<DocxEditor.Navigation />
<DocxEditor.Viewport>
<DocxEditor.VerticalRuler />
{/* Overlay chrome the packaged host mounts for you. A composed tree places it by
name, or the feature has no UI. */}
<DocxEditor.HeaderFooterChrome />
<DocxEditor.NotesChrome />
<DocxEditor.Content />
<DocxEditor.HyperLink />
<DocxEditor.ContextMenu />
</DocxEditor.Viewport>
<DocxEditor.PageNumber />
</div>
</DocxEditor.Root>
);
}Composing gets you every part by name. Parts you do not place have no UI, which is the work <DocxEditor> does for you.
Layout pitfalls
The navigation pane needs a positioning context. It is a sibling of the viewport inside a positioned row, not a column beside it. The pane floats over the gutter and absolutely positions against that box, so without position: relative on the row it lays out in the flow and pushes the page down.
Do not set z-index on the workspace row or the viewport. Either opens a stacking context around the context menu, and a position: fixed panel cannot escape the context it is declared in. It renders under the chrome bar however high its own z-index goes.
Keep the caret
Any mousedown that reaches the document moves the caret. Chrome that should not steal focus must prevent it:
<div onMouseDown={(e) => e.preventDefault()}>{/* your toolbar */}</div>Skip inputs, selects, and textareas, which need the focus. The packaged chrome already does this; hand-written chrome must.
Next steps
- Igloo example: every pattern above, running
- Custom nodes example: your own inline node types
- Hooks: the API every part above is built on
- Toolbar: the slot registry and the full part list
- Props:
<DocxEditor>and the imperative ref
@docx-editor.dev/react
React adapter for the DOCX editor: the packaged root component, provider primitives, shared hooks, and compound chrome, all from the package root.
Hooks
The React API the packaged chrome is built on: subscribe to editor state, run commands, read the document outline, search, and drive page setup.