If you've built an editor with TipTap / ProseMirror, you've probably heard this request: "Make it look like Word. Show real A4 pages, and print exactly what I see."
Sounds simple, but a contenteditable is one endless column of content. It has no idea what a page is.
This post walks through how I added A4 pagination to my open-source editor, Text Editor React, and packaged it as a free extension: tiptap-a4-pagination.
👉 Live demo: https://text-editor-react-neon.vercel.app/
The options I found
| Option | Problem |
|---|---|
| Tiptap Pages (official) | Paid Pro extension, private npm registry |
| Community extensions | Inject global CSS (change table display, word-break: break-all, force padding/width on the editor), which broke my existing styles |
| One editor per page | Cursor movement, undo and selections across pages become a nightmare |
I wanted one thing: don't modify the document, don't touch the user's CSS. editor.getHTML() should stay exactly as clean as before.
The idea: spacers as decorations
ProseMirror has decorations: elements that are rendered but are not part of the document. So:
- Measure every top-level block (paragraphs, headings, tables, images…).
- When a block would cross the bottom of a page, insert a widget decoration right before it.
- Make the widget exactly as tall as the rest of the page + bottom margin + the gray gap + the next page's top margin.
The block lands at the top of the next sheet, and visually you get separate pages. The document doesn't change by a single byte.
Decoration.widget(pos, () => createSpacer(pageBreak), { side: -1, ignoreSelection: true })
The layout is recomputed whenever the editor's height changes (typing, images loading, fonts arriving) with a ResizeObserver and requestAnimationFrame.
Don't trust the current positions
My first attempt used each block's getBoundingClientRect().top. The problem: the spacers I had already inserted shift those positions. Measure again, get a different answer, move the spacers, measure again… an endless loop.
What works is to rebuild the layout from heights and margins only, as if there were no spacers at all:
let cursor: number | null = null // bottom of the previous block
let pageEnd = layout.contentHeight
for (const block of blocks) {
// Adjacent vertical margins collapse, like in CSS
const top = cursor === null
? block.top
: cursor + Math.max(prevMarginBottom, block.marginTop)
const bottom = top + block.height
if (bottom > pageEnd && top > pageStart) {
// Spacer before this block: it starts at the top of the next page
breaks.push({ pos: block.pos, height: nextPageTop - block.marginTop - (cursor + prevMarginBottom) })
pageEnd += pitch
}
cursor = bottom
}
This function is pure: blocks in, page breaks out, no DOM. That made it easy to unit test, and the package has 13 tests for this part alone.
The traps I fell into
1. Margin collapse
Once a spacer sits between two paragraphs, their margins stop collapsing, and everything is off by a few pixels. On top of that, the first block's top margin "leaks" out of the editor, which shifted my origin by 16px. The fix was to measure from the top of the page's content area (the page's padding-top), not from the editor element.
2. Floated images
An image with float: left (text wrapping) doesn't push the next block down: the next paragraph sits beside it. The layout has to skip a floated block's height when placing the following block.
3. Splitting tables across pages
Pushing a long table to the next page as a whole looks terrible. Instead, inside a table the spacer is a <tr>: a widget decoration placed between two row nodes.
The biggest bug here: I gave the spacer cell colSpan = 1000, "to be safe". With table-layout: fixed, the browser created 1000 phantom columns and squeezed the real ones to almost nothing, so text stacked one character per line. The fix: use the real column count, TableMap.get(table).width from prosemirror-tables.
4. Rowspan
Inserting a spacer row in the middle of a cell with rowspan breaks the table instantly. Rows joined by a rowspan are grouped into one unbreakable group, and page breaks only go between groups.
5. Repeating the header row
Like Word, when a table continues on the next page, its header row shows again. I copy the first row's DOM (with the measured width of each cell, so the columns line up) into the end of the spacer row.
6. Zoom
Later I added a Google Docs–style zoom (50–200%) with a CSS transform: scale() on the page. Every getBoundingClientRect() suddenly returned scaled values, and page breaks moved around with the zoom level. Now the extension divides every measurement by the page's on-screen scale:
export function elementScale (element: HTMLElement): number {
return element.getBoundingClientRect().width / element.offsetWidth || 1
}
offsetWidth ignores transforms, so the ratio is the zoom. Page breaks are now identical at every level.
PDF export without a server
The project used to export PDFs through a Puppeteer backend. Now it prints through the browser (window.print() on a hidden iframe containing only the pages):
-
@page { size: A4; margin: ... }comes from the page settings. - Spacers are removed and
break-before: pagegoes on the block right after each one, so the PDF breaks exactly where the editor does. - Table header rows move into a
<thead>, and Chrome repeats them on every printed page. - Headers, footers and page numbers use CSS page margin boxes (Chrome 131+):
@page {
@top-left { content: "Quarterly report"; }
@bottom-right { content: "Page " counter(page) " of " counter(pages); }
}
Use it in your project
npm install tiptap-a4-pagination
import { Pagination, createPageLayout } from 'tiptap-a4-pagination'
import 'tiptap-a4-pagination/styles.css'
// Margins in mm
const layout = createPageLayout({ size: 'A4', marginTop: 25.4, marginBottom: 25.4 })
const editor = useEditor({
extensions: [StarterKit, Pagination.configure({ pageSelector: '.page' })]
})
useEffect(() => { editor?.commands.setPageLayout(layout) }, [editor])
About 5 KB gzipped, TipTap 3 only. Full docs are in the package README.
Limitations
- A single paragraph taller than the space left on a page moves to the next page as a whole; paragraphs are not split across two pages yet.
- A table row taller than a whole page overflows into the gap.
What else is in the editor
Comments in the margin, link sharing with real-time editing (peer-to-peer over WebRTC with Yjs, no backend), headers and footers, opening .docx files, Google Docs paste that keeps checklists and tables, slash commands, find & replace, and zoom. All MIT.
I'd love feedback, especially documents that break the layout. If you find it useful, a ⭐ on GitHub would mean a lot 🙏
Top comments (0)