Examples
End-to-end, copy-paste examples. Each one assumes the package is installed
(npm install @universal-office/write) and imported once so the custom
elements register themselves:
import '@universal-office/write';
Snippets reference an editor obtained with
const editor = document.getElementById('editor'); and, in the
comparison examples, a wd for the
<universal-write-diff> element.
Live demos
These run the real <universal-write> and
<universal-write-diff> components right here in your browser —
no backend. Open your own .docx, edit it, use the
toolbar, export it, type @ for a mention, and — in the comparison —
open either side and toggle the view. The copy-paste library below covers every
part of the API in isolation.
Editor
The toolbar and status bar are built into the component — just
toggle the boolean topbar and bottombar attributes, no
external chrome to wire.
Show source
import '@universal-office/write';
import { createMentionPlugin } from '@universal-office/write';
// The toolbar + status bar are built in — just set the attributes:
// <universal-write topbar bottombar></universal-write>
const ed = document.getElementById('ed');
// …or toggle them at runtime via properties:
ed.topbar = true; // boolean
ed.bottombar = true; // boolean
ed.use(createMentionPlugin({ prefix: '@', source: fetchPeople }));
ed.addEventListener('uw-ready', async () => {
const bytes = new Uint8Array(await (await fetch('/samples/demo.docx')).arrayBuffer());
ed.load(bytes, 'auto', { protected: false }); // editable straight away
ed.focusEditor();
});
Comparison
Show source
import '@universal-office/write';
const wd = document.getElementById('wd');
const bytes = async (u) => new Uint8Array(await (await fetch(u)).arrayBuffer());
wd.addEventListener('ready', (e) => {
const s = e.detail.stats;
console.log(`+${s.inserted} −${s.deleted} ~${s.changed} ✎${s.formatOnly}`);
});
let a, b, aLabel = 'rel-v1.docx', bLabel = 'rel-v2.docx';
const run = () => { if (a && b) wd.loadPair(a, b, { leftLabel: aLabel, rightLabel: bLabel }); };
a = await bytes('/samples/rel-v1.docx');
b = await bytes('/samples/rel-v2.docx');
run();
// Swap either side for a file from disk
openLeft.onclick = () => fileLeft.click();
fileLeft.onchange = async (e) => {
a = new Uint8Array(await e.target.files[0].arrayBuffer());
aLabel = e.target.files[0].name;
run();
};
// Toggle between the full document and changes only
viewButtons.forEach(btn => btn.onclick = () => { wd.view = btn.dataset.view; });
The demos above load small sample documents (a rich demo.docx for the editor,
and rel-v1/rel-v2 for the comparison) and render entirely on the
client. Everything below is a focused, copy-paste snippet library.
Editor
Minimal editor
The smallest working setup: a tag, an import, and a sample document loaded on ready.
<universal-write id="editor" fonts="/fonts" style="height:100vh"></universal-write>
import '@universal-office/write';
const editor = document.getElementById('editor');
editor.addEventListener('uw-ready', async () => {
const resp = await fetch('/samples/welcome.docx');
if (resp.ok) editor.load(new Uint8Array(await resp.arrayBuffer()), 'auto', { protected: false });
else editor.newDocument();
editor.focusEditor();
});
Formatting toolbar
Wire buttons to the run/paragraph formatting API. The editor keeps the model in sync.
<div class="toolbar">
<button data-cmd="bold"><b>B</b></button>
<button data-cmd="italic"><i>I</i></button>
<button data-cmd="underline"><u>U</u></button>
<button data-align="left">Left</button>
<button data-align="center">Center</button>
<button data-align="both">Justify</button>
<button data-list="bullet">• List</button>
<button data-list="decimal">1. List</button>
<select id="size"><option>11</option><option>14</option><option>18</option></select>
</div>
<universal-write id="editor" fonts="/fonts"></universal-write>
import '@universal-office/write';
const editor = document.getElementById('editor');
document.querySelectorAll('[data-cmd]').forEach((b) =>
b.addEventListener('click', () => editor.toggleRunProp(b.dataset.cmd)));
document.querySelectorAll('[data-align]').forEach((b) =>
b.addEventListener('click', () => editor.setAlignment(b.dataset.align)));
document.querySelectorAll('[data-list]').forEach((b) =>
b.addEventListener('click', () => editor.toggleList(b.dataset.list)));
document.getElementById('size').addEventListener('change', (e) =>
editor.setFontSize(Number(e.target.value)));
Undo, redo and keyboard
The editor handles its own shortcuts; expose buttons for a custom chrome:
undoBtn.onclick = () => editor.undo();
redoBtn.onclick = () => editor.redo();
selectAllBtn.onclick = () => editor.selectAll();
Files & export
Open and save .docx
Uses the File System Access API when available, with a download fallback for saving.
const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
async function open() {
const [handle] = await window.showOpenFilePicker({
types: [{ description: 'Word document', accept: { [DOCX]: ['.docx'] } }],
});
const file = await handle.getFile();
editor.load(new Uint8Array(await file.arrayBuffer()), 'auto', { protected: false });
}
async function save() {
download(await editor.save('docx'), 'document.docx', DOCX);
}
function download(data, filename, type) {
const url = URL.createObjectURL(new Blob([data], { type }));
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
a.click();
URL.revokeObjectURL(url);
}
Export menu
Every format goes through the same save(format) — always awaited.
pdfBtn.onclick = async () => download(await editor.save('pdf'), 'document.pdf', 'application/pdf');
odtBtn.onclick = async () => download(await editor.save('odt'), 'document.odt', 'application/vnd.oasis.opendocument.text');
xpsBtn.onclick = async () => download(await editor.save('xps'), 'document.xps', 'application/oxps');
htmlBtn.onclick = async () => {
const html = await editor.save('html', { title: 'My document' }); // returns a string
download(new TextEncoder().encode(html), 'document.html', 'text/html');
};
mdBtn.onclick = async () => {
const md = await editor.save('markdown', { images: false }); // omit images
download(new TextEncoder().encode(md), 'document.md', 'text/markdown');
};
printBtn.onclick = () => editor.print();
Insert an image
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.type.replace('image/', ''); // 'png' | 'jpeg' | 'gif'
await editor.insertImage(new Uint8Array(await file.arrayBuffer()), ext);
});
Interface
Reactive status bar
Keep a word/page count and zoom indicator in sync using events.
const status = document.getElementById('status');
editor.addEventListener('uw-change', render);
editor.addEventListener('uw-zoom', render);
editor.addEventListener('uw-ready', render);
function render() {
const st = editor.stats();
const zoom = Math.round(editor.getZoom() * 100);
status.textContent =
`Page ${st.page} of ${st.pages} · ${st.words} words · ${zoom}%`;
}
Zoom controls
zoomInBtn.onclick = () => editor.setZoom(editor.getZoom() + 0.1);
zoomOutBtn.onclick = () => editor.setZoom(editor.getZoom() - 0.1);
fitBtn.onclick = () => editor.setZoom(1);
"Enable Editing" bar
Show a banner while a freshly opened document is in Protected View.
editor.addEventListener('uw-protected', (e) => {
banner.hidden = !e.detail.active;
});
enableBtn.onclick = () => editor.enableEditing();
Find & replace
findBtn.onclick = () => {
const found = editor.findNext(findInput.value);
if (!found) status.textContent = 'No more matches';
};
replaceAllBtn.onclick = () => {
const n = editor.replaceAll(findInput.value, replaceInput.value);
status.textContent = `${n} replacement(s)`;
};
Comments panel
Add a comment on the selection, list them, and jump to one on click.
addCommentBtn.onclick = () => {
const id = editor.addComment(commentInput.value, 'Alice');
if (id === null) status.textContent = 'Select some text first';
};
editor.addEventListener('uw-comments-changed', renderComments);
function renderComments() {
panel.replaceChildren();
for (const c of editor.getComments()) {
const row = document.createElement('button');
row.textContent = `${c.author ?? 'Anon'}: ${c.text}`;
row.onclick = () => editor.goToComment(c.id);
panel.append(row);
}
}
// Clicking a comment mark inside the document reports the ids
editor.addEventListener('uw-comment-click', (e) => {
console.log('clicked comments', e.detail.ids);
});
Offline (no backend)
With manual-init, provide the fonts and the document as bytes — the
component makes no network request. Ideal for desktop shells and air-gapped apps.
<universal-write id="editor" manual-init></universal-write>
import '@universal-office/write';
const editor = document.getElementById('editor');
const load = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
await editor.initialize({
fonts: [
{ family: 'Liberation Sans', variant: 'regular', data: await load('/fonts/liberationsans-regular.ttf') },
{ family: 'Liberation Sans', variant: 'bold', data: await load('/fonts/liberationsans-bold.ttf') },
{ family: 'Liberation Sans', variant: 'italic', data: await load('/fonts/liberationsans-italic.ttf') },
{ family: 'Liberation Sans', variant: 'boldItalic', data: await load('/fonts/liberationsans-bolditalic.ttf') },
],
document: { bytes: await load('/samples/welcome.docx'), format: 'docx', protected: false },
});
Plugins
Mentions
Type @ to open a suggestions popover backed by your API.
import { createMentionPlugin } from '@universal-office/write';
editor.use(createMentionPlugin({
prefix: '@',
background: '#dce8ff',
color: '#174ea6',
maxSuggestions: 8,
emptyLabel: 'No people found',
source: async (query) => {
const resp = await fetch(`/api/people?q=${encodeURIComponent(query)}`);
return resp.json(); // [{ id, label }, ...]
},
}));
Custom inline (chip)
Register a renderer, then insert atomic inlines that survive save/reopen.
const chips = {
id: 'myapp.chips',
inlineRenderers: [{
type: 'chip',
toText: (data) => data.name,
measure: ({ fallbackText, measureText }) => ({ widthTwips: measureText(fallbackText) + 200 }),
draw: ({ ctx, xTwips, yTwips, scale, fallbackText }) => {
ctx.fillStyle = '#e8def8';
ctx.fillRect(xTwips * scale, yTwips * scale, 300 * scale, 100 * scale);
ctx.fillStyle = '#381e72';
ctx.fillText(fallbackText, (xTwips + 50) * scale, yTwips * scale);
},
}],
};
editor.use(chips);
insertChipBtn.onclick = () => editor.insertCustomInline('chip', { name: 'Proposal' });
Comparison
Compare two files
Feed two .docx files into <universal-write-diff>.
<input type="file" id="a" accept=".docx">
<input type="file" id="b" accept=".docx">
<universal-write-diff id="wd" view="all" left-label="Original" right-label="Modified"></universal-write-diff>
import '@universal-office/compare';
const wd = document.getElementById('wd');
const bytesOf = async (input) => new Uint8Array(await input.files[0].arrayBuffer());
async function run() {
const a = document.getElementById('a');
const b = document.getElementById('b');
if (!a.files[0] || !b.files[0]) return;
wd.loadPair(await bytesOf(a), await bytesOf(b), {
leftLabel: a.files[0].name,
rightLabel: b.files[0].name,
});
}
document.getElementById('a').addEventListener('change', run);
document.getElementById('b').addEventListener('change', run);
wd.addEventListener('ready', (e) => {
const s = e.detail.stats;
console.log(`+${s.inserted} −${s.deleted} ~${s.changed} ✎${s.formatOnly}`);
});
Compare with the current document
Serialize the editor's state and diff it against a chosen file — the desktop app's "Compare with…" flow.
async function compareWith(file) {
const current = await editor.save('docx');
const other = new Uint8Array(await file.arrayBuffer());
wd.loadPair(current, other, { leftLabel: 'Current document', rightLabel: file.name });
editor.style.display = 'none';
wd.style.display = 'flex';
}
wd.addEventListener('close-request', () => {
wd.style.display = 'none';
editor.style.display = '';
});
Programmatic diff (report / CI)
Skip the UI and compute the diff directly — useful for change reports or gating a pipeline.
import { diffDocuments, DocxPackage, readFullDocument } from '@universal-office/compare';
const a = readFullDocument(DocxPackage.load(aBytes));
const b = readFullDocument(DocxPackage.load(bBytes));
const { edits, stats } = diffDocuments(a, b, { algorithm: 'histogram', intraWord: true });
if (stats.changed + stats.inserted + stats.deleted > 0) {
console.log('Documents differ:', stats);
for (const e of edits) {
if (e.kind !== 'equal') console.log(e.kind, 'blocks', e.a, '→', e.b);
}
}