The application required citations in its documents. A citation appears as
(Smith 2024) on the page. It stores sourceId, author, and year, and
renders as an editable chip. These properties remain after someone opens and
saves the file in Word.
DOCX has no citation type. It does have the structured document tag, w:sdt,
which Word calls a content control. Word preserves an SDT and its bound
customXml part without interpreting the contained data. You can put a custom
element type in a content control, and Word preserves it during a save.
@docx-editor.dev/pro defines a convention for the tag and data part. It also
provides a typed API, so you do not need to write OOXML.
The application also requires chips, popovers, and a toolbar that matches its other controls. These components require no editor fork because the packaged editor uses the same public engine API.
The code is React. The Vue adapter has the same parts under the same names. The engine is a peer dependency:
npm install @docx-editor.dev/react @docx-editor.dev/coreCustom nodes add @docx-editor.dev/pro, which is licensed for evaluation rather
than under Apache 2.0.
The smallest possible editor
import { DocxEditor } from '@docx-editor.dev/react';
import '@docx-editor.dev/core/styles/editor.css';
export function Editor({ bytes }: { bytes: Uint8Array }) {
return (
<DocxEditor.Root document={bytes}>
<DocxEditor.Viewport>
<DocxEditor.Content />
</DocxEditor.Viewport>
</DocxEditor.Root>
);
}This code creates a working editor without chrome. Root owns the instance and
takes the document-level props. Viewport handles scrolling and page layout,
and Content renders the pages. Place other components anywhere inside Root.
Using the same parts as the packaged editor
The packaged toolbar uses a public UI API. Its buttons read state and run commands through the same public contract as custom controls. You can therefore use all or part of the packaged toolbar.
Replacing one control keeps every other default in place, including slots added in subsequent package releases:
<DocxEditor.Toolbar>
<DocxEditor.Toolbar.Bold icon={MyBold} />
<DocxEditor.Toolbar.Highlight hidden />
</DocxEditor.Toolbar>icon takes a React element, not a component function. To use custom markup
with the packaged behavior, set asChild. It merges the part's handlers,
disabled state, active state, and Accessible Rich Internet Applications (ARIA)
attributes into the custom element. It does not render a wrapper:
<DocxEditor.Toolbar.Bold asChild>
<Button variant="ghost">Bold</Button>
</DocxEditor.Toolbar.Bold>If the packaged order does not meet your requirements, arrange the toolbar parts directly:
<DocxEditor.Toolbar preset={false} className="my-toolbar">
<DocxEditor.Toolbar.Undo />
<DocxEditor.Toolbar.Redo />
<DocxEditor.Toolbar.Separator />
<DocxEditor.Toolbar.StylePicker className="my-picker" />
<DocxEditor.Toolbar.FontFamily className="my-picker" />
<DocxEditor.Toolbar.FontSize />
<DocxEditor.Toolbar.Separator />
<DocxEditor.Toolbar.Bold />
<DocxEditor.Toolbar.Italic />
<DocxEditor.Toolbar.FontColor />
<div className="my-toolbar__spacer" />
<DocxEditor.Toolbar.Zoom />
</DocxEditor.Toolbar>preset={false} removes the default arrangement and uses the template order.
The parts keep their commands and state. The toolbar contains only the listed
parts. A later registry addition does not appear until you add it.
For controls without a slot, useEditorCommand returns the same command state
that the packaged parts use:
import { useEditorCommand } from '@docx-editor.dev/react';
function BoldButton() {
const bold = useEditorCommand('text.bold');
return (
<button
onMouseDown={(e) => e.preventDefault()}
onClick={() => bold.execute()}
disabled={!bold.isEnabled}
data-active={bold.isActive || undefined}
title={bold.disabledReason ?? 'Bold'}
>
B
</button>
);
}The preventDefault() matters because a mousedown can move the caret before the
command runs. Skip it on input, select, and textarea, which need focus.
isEnabled comes from the engine rather than a custom rule. The toolbar button
and context menu therefore use the same state for an action.
useEditorState takes a selector. Pass s.page instead of the complete
snapshot. Otherwise, the page indicator renders again when someone toggles
bold:
import { useEditorState } from '@docx-editor.dev/react';
function PageIndicator() {
const page = useEditorState((s) => s.page);
return (
<span>
{page.current} / {page.total}
</span>
);
}Defining the element
A custom node defines one element type. The definition specifies its page appearance and stored data.
import { z } from 'zod';
import { customNodesModule, defineCustomNode } from '@docx-editor.dev/pro';
export const Citation = defineCustomNode({
name: 'citation', // second segment of the tag
tagPrefix: 'acme', // this definition claims acme:*
schema: z.object({
sourceId: z.string().min(1),
author: z.string(),
year: z.number().int(),
}),
text: (data) => `(${data.author} ${String(data.year)})`,
});
// Built once, outside render: modules are read when the editor is constructed.
export const MODULES = [customNodesModule({ nodes: [Citation] })];The schema validates data from .docx files before the application uses it.
The editor refuses invalid data before a write. Returned data has the
Citation type instead of unknown. Any
Standard Schema implementation works.
Pass the modules to the root:
<DocxEditor.Root document={bytes} modules={MODULES}>
{/* viewport, content, and chrome */}
</DocxEditor.Root>The content control representation
Each citation is written as an inline w:sdt. Its identity goes in the
control's w:tag, and its payload in a customXml part the control binds to. In
customXml/item1.xml:
<docxEditor xmlns="urn:docx-editor.dev:custom-node:acme">
<node id="cx1">
<label>(Smith 2024)</label>
<data>{"sourceId":"smith-2024","author":"Smith","year":2024}</data>
</node>
</docxEditor>And in word/document.xml, inside the control:
<w:dataBinding
w:prefixMappings="xmlns:ns0='urn:docx-editor.dev:custom-node:acme'"
w:xpath="/ns0:docxEditor/ns0:node[@id='cx1']/ns0:label"
w:storeItemID="{...}"/>Word renders the control's text and preserves the customXml part. Both remain when the application opens the saved file. This representation uses standard Word features. Word does not interpret the citation data. The application reads that data from the preserved container.
The same structure supports mentions, clause references, and merge tokens. Each type has a display string, a structured payload, and a definition that pairs them.
Inserting a citation
import { insertCustomNode } from '@docx-editor.dev/pro';
import { useDocxEditor } from '@docx-editor.dev/react';
import { Citation } from './citation';
function CiteButton() {
// null before the Root's mount effect creates it, and outside any Root
const editor = useDocxEditor();
return (
<button
disabled={!editor}
onClick={() => {
const result = insertCustomNode(editor!, Citation, {
data: { sourceId: 'smith-2024', author: 'Smith', year: 2024 },
});
if (!result.ok) console.warn(result.reason);
}}
>
Cite
</button>
);
}This inserts (Smith 2024) at the caret. insertCustomNode,
updateCustomNode, and removeCustomNode each run in one transaction and
create one undo step. Ctrl+Z therefore removes the complete citation. The editor
refuses a payload that fails the schema. The refusal includes per-field
issues that you can display in the form.
Rendering the citation as a chip
The document still contains ordinary DOCX text. CustomNodeChrome renders
custom controls over recognized nodes. The editor locks node content, so edits
use the context menu instead of the caret. Both parts mount inside the
viewport:
import {
CustomNodeChrome,
CustomNodeContextMenu,
} from '@docx-editor.dev/pro/react';
<DocxEditor.Viewport>
<DocxEditor.Content />
<CustomNodeChrome onNodeClick={(node) => openPopover(node)} />
<DocxEditor.ContextMenu>
<CustomNodeContextMenu onEditNode={(node) => openEditForm(node)} />
</DocxEditor.ContextMenu>
</DocxEditor.Viewport>;An active node provides name, attrs, tag, data, and a
viewport-relative rect for the popover anchor. The callbacks receive nodes
from all definitions under one type, so data has the unknown type. dataOf
narrows the node to one definition and validates its payload:
<CustomNodeChrome onNodeClick={(node) => open(Citation.dataOf(node))} />Keep the working document separate from the export
Saving and exporting are separate operations. Store the saved document, not the exported document.
import { saveForExport } from '@docx-editor.dev/pro';
await storage.put(docId, new Uint8Array(await editor.save()));
const outgoing = await saveForExport(editor);
if (!outgoing.ok) throw new Error(outgoing.reason);
download(outgoing.bytes);editor.save() creates the stored copy, which reopens with functional chips.
saveForExport creates the distributed copy. For each definition,
preserveOnExport controls the result. true preserves the complete node,
'text' preserves the text without the control, and false removes both.
Store the saved bytes, not the exported bytes. Text from a flattened node does
not become a node when the editor reads it again.
customNodesOf(editor) returns recognized nodes in document order. Use this
function after Word saves the document and the application reopens the updated
file.
Implementation details
Modules are read when the editor is constructed. They do not behave like
normal React props. The editor ignores a new array identity after mount. It
reads a change only when a new document or fonts value rebuilds the editor.
Build the array at module scope to keep its identity stable.
An update returns a new node ID. updateCustomNode replaces the control, so
the previous ID stops resolving. Code that stores the previous ID needs the new
one.
Do not set z-index on the workspace row or viewport. The z-index creates
a stacking context that places the context menu under the custom toolbar.
The navigation pane needs a positioning context. Put it beside the viewport
in a row with position: relative. Without this context, normal flow moves the
page horizontally.
Next steps
To add a custom element type to a Word document, put it in a content control. Word preserves the control, and your application reads its payload. Your application can then render its required controls without a private file format or editor fork. The custom nodes reference covers every option and every refusal code.