npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@betteroffice/docx-react

v0.4.3

Published

Word-faithful React DOCX editor and viewer — full OOXML fidelity, real-time human + AI agent collaboration.

Readme

@betteroffice/docx-react

React chrome for the BetterOffice DOCX editor — wraps @betteroffice/docx in a drop-in <DocxEditor> component with the toolbar, ruler, selection, keyboard, comments, and tracked-changes UI wired up. Rendering and layout run in the core's Rust/WebAssembly engine; pages are painted onto canvas.

bun add @betteroffice/docx-react @betteroffice/docx react react-dom

react and react-dom (18 or 19) are peer dependencies.

View a document

import { DocxEditor } from '@betteroffice/docx-react';
import '@betteroffice/docx-react/styles.css';

<DocxEditor documentBuffer={buffer} mode="viewing" />;

documentBuffer accepts an ArrayBuffer, Uint8Array, Blob, or File.

Edit a document

import { useState } from 'react';
import { DocxEditor } from '@betteroffice/docx-react';
import '@betteroffice/docx-react/styles.css';

export function App() {
  const [file, setFile] = useState<ArrayBuffer>();

  return (
    <>
      <input
        type="file"
        accept=".docx"
        onChange={async (e) => {
          const f = e.target.files?.[0];
          if (f) setFile(await f.arrayBuffer());
        }}
      />
      <DocxEditor
        documentBuffer={file}
        onSave={(bytes) => console.log(`saved ${bytes.byteLength} bytes`)}
      />
    </>
  );
}

Without onSave, File > Save downloads the edited bytes.

Key props: documentBuffer (or a parsed document), onSave, onChange, author, mode (editing / suggesting / viewing), showToolbar, toolbar, showRuler, showZoomControl, showHiddenText, i18n, measurementFontProvider. The ref exposes the full editor API (selection, formatting, find/replace, comments, revisions) and the editor's command store, commands.

Vanished (hidden) text stays out of the layout by default; pass showHiddenText to reveal it with normal wrapping.

previewFirstPage (experimental, off by default) paints a display-only preview of a document's first pages before the whole document opens, then hands them over to it; the editor stays read-only until then. It is ignored with collaboration.

onFirstPagePainted is called once per document when its first pages are painted on screen, from the preview or the full document, so a host can drop its loading state before the whole document is open.

What works today

  • Editing with Word-faithful pagination; layout runs in Rust, never in the DOM
  • Suggesting mode: tracked changes with accept/reject review UI
  • Comment threads with replies and resolution, controllable from the host
  • Find and replace, headers and footers, footnotes, images, tables
  • Zoom control and ruler
  • Composable chrome: arrange built-in controls with their state, shortcuts and restrictions, add host actions, or drive a toolbar outside the editor
  • Host-owned plugins with panels, overlays, sidebar items, lifecycle events, and explicitly granted commands and edit batches
  • Localized UI via the i18n prop (@betteroffice/docx-i18n)
  • Real-time collaboration with people or agents; the document is a CRDT
  • Live collaborator cursors and selections, shown in each peer's color

For font metrics closer to Word, configure @betteroffice/fonts once at module scope, or pass a provider through measurementFontProvider. The CDN entry loads Latin and Japanese coverage fonts without bundling font binaries:

import { configureDefaultFonts } from '@betteroffice/docx-react';

configureDefaultFonts({ load: () => import('@betteroffice/fonts/cdn') });

For offline assets, import * as fonts from @betteroffice/fonts and call configureDefaultFonts({ fonts }); see font configuration.

Several editors can share one page: each hears only its own font loads through onFontsLoaded and onError, paints a document's embedded faces even when another open document embeds different faces under the same family name, and releases those faces when it unmounts or loads its next document.

Collaboration

The document is a CRDT — pass a collaboration prop and wire a transport to co-edit with other people or an agent. onReplica hands you the session as a CollaborationReplica; drive it with a CollaborationProvider over any transport (a WebSocket relay, etc.). Every window must boot from the same shared state, so pass identical initialUpdate bytes to each peer.

import { CollaborationProvider } from '@betteroffice/docx/collaboration';

<DocxEditor
  documentBuffer={file}
  collaboration={{
    clientId,
    initialUpdate: sharedSeed, // identical bytes for every peer
    onReplica: (replica) => {
      if (!replica) return;
      const provider = new CollaborationProvider(replica, transport, {
        user: { name: "Ada" }, // shown on this peer's remote caret
      });
      provider.connect();
    },
  }}
/>;

Host saves and pending input

onSaveRequest owns File > Save and Cmd/Ctrl+S before serialization. Its awaited callback can perform locks, revision checks, and persistence. Returning true continues the built-in export/download; returning false or nothing suppresses it. Overlapping UI save requests share one workflow. Errors reach onError.

DocxEditorRef.flushPendingInput() and PagedEditorRef.flushPendingInput() wait until input accepted before the call and its selection are authoritative in Yrs. They wait for active IME composition to end, and reject on input failure, unmount, or document replacement. An input failure rejects only the flushes, saves and commands that waited for that input; later ones proceed.

<DocxEditor
  ref={editorRef}
  onSaveRequest={async () => {
    await validateRevision();
    await editorRef.current!.flushPendingInput();
    const bytes = await editorRef.current!.save();
    if (bytes) await persistDocument(bytes);
  }}
/>

save() flushes input and exports directly, so calling it inside onSaveRequest does not invoke that callback again. onSave(buffer) remains the notification after export. Before direct session reads or mutations, await flushPendingInput(); after a mutation, use syncYrsInputState(true) to refresh the editor.

For a document opened from DOCX bytes, Save writes the session's Word paragraph IDs, including those persistParagraphIds() gave comments, note separators and retained XML, and records them as saved, as saveYrsDocx does. Use saveYrsDocx for the persisted anchors of the saved paragraphs.

Compose the toolbar

Every built-in control runs through one command store, ref.commands, which hosts can use for their own chrome. Pass toolbar to replace the default chrome with an arrangement of public parts; the children of EditorToolbar.Toolbar are the complete row, in your order:

import {
  DocxEditor,
  EditorToolbar,
  ToolbarButton,
  ToolbarCommandButton,
  ToolbarCommandSelect,
  ToolbarGroup,
} from '@betteroffice/docx-react';

function CompactToolbar({ onShare }: { onShare(): void }) {
  return (
    <EditorToolbar>
      <EditorToolbar.Toolbar>
        <ToolbarGroup label="Formatting">
          <ToolbarCommandSelect id="paragraphStyle" />
          <ToolbarCommandButton id="bold" />
        </ToolbarGroup>
        <ToolbarCommandButton id="undo" />
        <EditorToolbar.Review />
        <ToolbarButton title="Share" onClick={onShare}>Share</ToolbarButton>
      </EditorToolbar.Toolbar>
    </EditorToolbar>
  );
}

<DocxEditor documentBuffer={file} toolbar={<CompactToolbar onShare={share} />} />;

toolbar omitted renders the default chrome, null renders none, and showToolbar={false} hides either. The default chrome is hidden for readOnly; chrome you supply still renders, with its writing controls disabled.

To place the toolbar outside the editor, capture its commands in state; commands={null} reports every command unavailable:

import { useState } from 'react';
import { DocxCommandProvider, DocxEditor, type DocxCommandStore } from '@betteroffice/docx-react';

function Editor({ share }: { share(): void }) {
  const [commands, setCommands] = useState<DocxCommandStore | null>(null);
  return <>
    <DocxCommandProvider commands={commands}>
      <CompactToolbar onShare={share} />
    </DocxCommandProvider>
    <DocxEditor ref={(editor) => setCommands(editor?.commands ?? null)} toolbar={null} />
  </>;
}

Outside the editor, EditorToolbar supplies its own styling root and the editor's locale, and its keyboard shortcuts reach that editor only.

  • Parts. ToolbarCommandButton binds a command (parameterized ones need args, such as { value: 'center' } for alignment), ToolbarCommandSelect renders a selector's built-in picker, ToolbarCommand renders any command's default control, and ToolbarButton, ToolbarGroup and ToolbarSeparator hold host actions. useDocxCommand(id) and useDocxCommandState(id) bind custom controls.
  • State. getState(id, args?) returns { enabled, active, value, options }; a disabled command always carries disabledReason: { code, message }, and controls expose that message as their accessible description. Marks report 'mixed' for mixed selections.
  • Results. execute(id, args) resolves to { ok: true, status } (executed, noop, opened for a dialog or picker, requested when handed to the host) or { ok: false, failure }. Commands run after input accepted before the call and check availability again first, so a stale enabled state never authorizes a change. A dialog or picker a command opens applies to the document and selection it opened with, or fails with document-replaced or target-changed; print waits until accepted input is rendered.
  • Modes. readOnly and viewing mode refuse writes (read-only, viewing-mode); navigation, find, zoom, print and save remain available. Suggesting mode tracks paragraph styles and row edits, keeps direct character formatting untracked, and refuses structural commands such as table and break insertion with suggesting-unsupported.
  • Focus and overflow. Pointer clicks keep the document focused; keyboard activation keeps focus in the toolbar, and dialogs take focus. At narrow widths trailing groups move into a More menu with arrow, Home/End, typeahead and submenu navigation, keeping every choice of the built-in controls; custom colors and sizes are asked for in a dialog. Wrap host content in ToolbarOverflow to give it a menu entry; content without one, and any group holding it, stays in the row, which scrolls horizontally when that content does not fit.

Host edit batches

DocxEditorRef.readParagraphs(), findText(), validateEdits() and applyEdits() flush pending input first, then run the version-checked batch API of @betteroffice/docx on the open document. Build expectVersion and targets from a flushed read:

const read = await editorRef.current!.readParagraphs({ view: 'accepted' });
if (!read.ok) return;
const result = await editorRef.current!.applyEdits({
  expectVersion: read.version,
  steps: [
    {
      op: 'insertParagraphs',
      target: { story: 'body', paraId: read.paragraphs[0].paraId },
      at: 'end',
      paragraphs: [{ text: 'Inserted by the host', styleId: 'Heading2' }],
    },
  ],
});

A batch commits as one undo step and refreshes every story it changed, or returns a typed refusal. A refusal never rolls back typing that the flush committed, so typing that lands between the read and the batch refuses with stale-version. Read-only editors refuse with read-only (locked-target stays for locked content); in suggesting mode every step must carry suggest. The promise rejects when the document is replaced while input flushes. proposeChange, addComment and applyFormatting resolve their { paraId, search } targets through the same Rust resolver in the accepted view.

getPositionAtPoint(clientX, clientY) returns the text under a client point, such as a drop target, with the version its layout shows and a collapsed accepted-view target to insert at with expectVersion: version. It never moves selection or focus, and returns null outside text, while typed or composed input is pending, and until the painted pages show the current version; retry after flushPendingInput() or on the next frame.

listContentControls() and findContentControls(query) flush the same way and list the document's content controls with the version they were read at; fill plain- and rich-text controls with setContentControlText steps through applyEdits, which refreshes the control's story and the story holding it.

Host proposals

DocxEditorRef.proposeChanges(), setProposalStates(), withdrawProposals() and getProposals() flush pending input, then run the session's host proposals: a round of tracked changes grouped by the host's proposal ids, shown with the editor's tracked-change highlighting, and decisions that show accepted proposals as plain text and hide rejected ones without changing the document or undo history. withdrawProposals() settles finished proposals as their decisions show them, outside undo history, so the next round searches the text the reader saw. Set allowHostProposals to use them in a read-only or viewing editor; typing, applyEdits, commands and plugin writes stay blocked there. Proposals never save or open the comments sidebar.

const round = await editorRef.current!.proposeChanges({
  expectVersion: (await editorRef.current!.getProposals()).version,
  proposals: [
    {
      id: 'p-17',
      paragraph: { kind: 'persisted', story: { partUri: '/word/document.xml', kind: 'body' }, paraId: '1A2B3C4D' },
      suggest: { author: 'Reviewer', date: new Date().toISOString() },
      op: 'replaceText',
      search: '30 days',
      replaceWith: '45 days',
    },
  ],
});
if (round.ok) {
  await editorRef.current!.setProposalStates({
    expectVersion: round.snapshot.version,
    expectPreviewVersion: round.snapshot.previewVersion,
    changes: [{ id: 'p-17', state: 'accepted' }],
  });
}

The input, result and refusal-code types (DocxProposalRequest, DocxProposalResult, DocxProposalStateRequest, DocxProposalWithdrawRequest, DocxProposalSnapshot, DocxProposalFailure, and related types) are re-exported from @betteroffice/docx-react -- no need to import @betteroffice/docx/yrs directly just to type host proposal code.

Host search

DocxEditorRef.search(query, options?) drives the editor's find from a host's own search box. It highlights every match in the document body, tables included, makes the first match on or after the page in view current and scrolls it to the middle of the view, without moving the selection or focus. It works in a read-only editor and on pages that are not painted yet. Matching is case-insensitive unless options.caseSensitive is set, and an empty query clears.

const state = await editorRef.current!.search('warranty');
label.textContent = state.total ? `${state.current + 1} of ${state.total}` : 'No matches';

editorRef.current!.searchNext(); // Enter
editorRef.current!.searchPrevious(); // Shift+Enter
editorRef.current!.clearSearch(); // closing the box

const unsubscribe = editorRef.current!.onSearchChange((state) => render(state));

searchNext(), searchPrevious() and searchGoTo(index) wrap around, scroll the new current match into view and return the new state, or null without a search. getSearchState() reads it, and onSearchChange reports every change, including a re-run after the document changes, which keeps the current match, and null on clear. Highlights use the .docx-find-highlight and .docx-find-highlight-current classes, which a host stylesheet can restyle.

Host plugins

Host-owned tools (review aids, templates, checks) install through the plugins prop. A plugin contributes a panel, an overlay, sidebar cards, and commands, and works through restricted clients rather than the editor or its session:

import { DocxEditor, defineDocxPlugin } from '@betteroffice/docx-react';

type State = { version: string | null; count: number };

const review = defineDocxPlugin<State>({
  id: 'acme.review',
  createState: () => ({ version: null, count: 0 }),
  async onEvent(context, event) {
    if (event.type !== 'load' && event.type !== 'document-change') return;
    const read = await context.read.readParagraphs({ view: 'accepted' });
    if (read.ok) {
      context.setState({ version: read.version, count: read.paragraphs.length }, read.version);
    }
  },
  panel: {
    title: 'Review',
    placement: 'right',
    render: ({ context }) => <p>{context.state.count} paragraphs</p>,
  },
});

<DocxEditor
  documentBuffer={file}
  plugins={[review]}
  pluginGrants={{ 'acme.review': { document: 'write', editBatches: true } }}
  onPluginError={(error) => report(error)}
/>;
  • Lifecycle. Once a document is ready, each plugin gets fresh state, initialize, then one load event (loaded, replaced, or attached for a plugin added to an open document), and its contributions appear. A document change the hook did not make itself aborts it and delivers load again; after ten such runs the plugin is stopped and reported. It then receives document-change (the committed version only, for typing, remote edits, undo, commands and batches, its own included, never for refusals or no-ops), selection-change, mode-change (with the effective readOnly), layout-change, proposal-change (host proposals or their preview decisions changed, with previewVersion) and grants-change. Events describe current state: several changes may arrive as one, and a newer one aborts the hook still handling the previous (context.signal), except a change that hook's own edit batch made. Replacing the document, removing the plugin, changing its revision, unmounting, or a failure ends the activation: its signals abort, its clients refuse, and every onCleanup disposer runs once with the reason. A disposer registered afterwards runs immediately. Plugins are matched by id and revision, so new array or callback identities and reordering keep their state. context.run(action) gives event handlers a fresh context and isolates their failures.
  • Stale results. setState returns false once the context is superseded or ended, or when the document is no longer at atVersion (by default the version the context was created at). Pass the version a read returned so an older read never overwrites newer state.
  • Grants. Without a grant a plugin reads, validates and navigates only, and context.edits is null. Built-in commands need their id in commands, and mutating ones also document: 'write'; edit batches need document: 'write' and editBatches, and history: 'none' also untrackedHistory. Grants, editor mode and document policy are checked again right before each change, so a revoked grant, viewing mode, readOnly or a replaced document refuses even through a client obtained earlier. Mutating built-in commands refuse plugins with unsupported-policy; plugins change documents through edit batches, whose Rust policy refuses locked content. Suggesting mode follows each operation's own rules; a batch step needs suggest there.
  • Contributed commands register as plugin:<pluginId>/<id> on ref.commands. They always run with their own plugin's clients, even when the host's toolbar, shortcuts or ref.commands invoke them, and outside the input queue, so a handler can await its own batches. execute returns { ok: true, status } or a failure with the plugin's own code, or a refused edit batch as-is; callers receive it unchanged. mutatesDocument disables a command in viewing and read-only modes but grants nothing. Plugin shortcuts must use Mod or Alt, or a function key; built-in and clipboard shortcuts win, and a clashing plugin shortcut is not bound and is reported. Keys pressed in plugin chrome are the user's: built-in shortcuts act on the editor there, and a plugin's text fields keep their own editing keys. toolbar lists local ids in order: the default toolbar shows them, and replacement chrome places <DocxPluginToolbar /> where it wants them. ToolbarCommandButton, ToolbarCommand, useDocxCommand and useDocxCommandState also take a contributed id; a button renders nothing while no plugin contributes it.
  • Panels dock left, right or bottom of the document, beside the toolbar rather than inside the scroll area, with tabs when several share a side. preferredSize is clamped to 40% of the editor. In a narrow editor side docks show their tabs, and a tab opens its panel as a drawer over the document that Escape closes. Collapsing a panel keeps the plugin running.
  • Geometry. context.geometry is null until a rendered layout shows the current version, and whenever it falls behind. geometry.dom answers in pages-container units divided by zoom; geometry.toOverlayRect(rect) converts one of those rectangles into pixels of the unscaled overlay layer, once, or returns null after that layout stops being rendered. geometry.getPositionAtPoint(clientX, clientY) returns the text under a client point with the layout's layoutId and version and an edit batch target, or null likewise, while input is pending, and until the pages show that layout. geometry.getAnchorGeometry(target) resolves a proposal, revision, paragraph, search match or text range to overlay-layer pixels: every visible fragment with its zero-based pageIndex, an anchor collapsed at the end of the last one (at the boundary of a target the preview hides, else at its paragraph) and the anchor's pageRect. It refuses with a typed failure rather than answer from a stale or unpainted layout, and layout-change repeats once the pages have painted a layout that arrived before its pixels; layout.previewVersion is the proposal preview the pixels show. The layer ignores the pointer; interactive overlay elements set pointer-events: auto. snapshot.selection.displayRange belongs to one layout and is never an edit target.
  • Sidebar cards anchor to { version, story, paraId } and show only while the document is at that version and the body paragraph resolves uniquely. render receives the item it draws, so one component can draw every card; a card keeps its React state while the plugin returns its id, and starts afresh for a new id or document. Their ids are namespaced, so they never collide with comments.
  • Navigation. navigation.scrollToParagraph(target, { expectVersion }) flushes input, waits briefly for a layout of that version, and keeps focus and selection unless focus: true. It succeeds only when it scrolled, and otherwise says why (stale-version, missing-target, ambiguous-target, layout-unavailable, unsupported).
  • Failures. Every contribution renders behind its own error boundary with a command context restricted to its plugin, so public command hooks inside it act for the plugin. A throwing hook, renderer, sidebar or command-state function, command, or context.run action stops only that plugin, runs its cleanups and reports { pluginId, generation, phase, error } to onPluginError; it stays stopped until a new document, revision or reinstallation. Plugins run in the page's realm: grants govern the supported API, not a sandbox.
  • Legacy props. pluginOverlays and pluginSidebarItems still render as unmanaged content, below managed overlays and merged with managed cards; pluginRenderedDomContext is ignored while plugins are installed, and onRenderedDomContextReady receives the editor's own context. The snapshot-based EditorPluginCore types in @betteroffice/docx/plugin-api are deprecated in favour of this API.

Page-referenced export

DocxEditorRef.exportStructuredWithPages(options) flushes pending input and exports structured content with the page map of the editor's authoritative layout of that version, the same map @betteroffice/docx documents. The layout must have been computed from the editor's current fonts, measurement defaults, render environment and pagination options; when it was not, the editor lays the document out again or waits up to two seconds for its fonts. Page references describe that layout, which a later edit may supersede before it is painted:

const result = await editorRef.current!.exportStructuredWithPages({
  revisionView: 'markup',
  stories: ['body', 'headers', 'footers'],
  includeGeometry: true,
});
if (result.ok) {
  const { structured, layout } = result.content;
  // layout.pages, layout.occurrences, layout.fragments; result.version
}

Geometry is in unzoomed CSS pixels from each page's top-left corner, so zoom and scrolling do not change it. With expectLayoutVersion the editor never lays out again and a newer document is refused as stale-document. The promise rejects when the document is replaced meanwhile.

Memory

Each wasm32 memory holds at most 4 GiB. DocxEditorRef.getMemoryStats() reports the editor's wasm memories on the main thread and in its resident worker: each module's memory size, which only grows and so is also its high-water mark, and for the editing core the bytes allocated now, the most allocated at once, and the size of an allocation that failed. onMemoryPressure is called when the fullest memory crosses a memoryBudget level, 75% and 90% of 4 GiB by default, and when it drops back; it stays silent below the warning level. The editing core counts by its allocated bytes and the other modules by their memory size.

<DocxEditor
  documentBuffer={bytes}
  onMemoryPressure={({ level, stats }) => report(level, stats)}
/>

memoryBudget.workerLimitBytes caps what the resident worker's editing core may allocate at once; an allocation past it fails as if the memory were full. A worker that runs out of memory is replaced by a fresh one once. If that one runs out too, the editor reports ResidentWorkerOutOfMemoryError from @betteroffice/docx/yrs through onError, with the worker's memories at that point, and stops rendering rather than moving the work to the main thread. Any other worker failure moves the work to the main thread.

Framework notes

Import @betteroffice/docx-react/styles.css once at the entry/page/layout level. In Next.js, load the editor with next/dynamic and ssr: false.

JavaScript guide · Changelog · Apache-2.0.