So you want to have paginated content?
An insight into the journey of implementing pagination in a rich text editor.
The web isn’t designed for it.
Simple as that, the web is designed to be one continuous flow of text. One could obviously argue that a page could just be a div followed by a page break, but it’s not that simple, especially if you want to be performant… If you’ve perused the rich text editor space long enough, you’ve probably seen a lot of pagination implementations, but most of them either rely on:
- A client-side canvas solution, painting every pixel in the browser;
- A server-side solution, streaming the rendered document to a canvas;
- A ‘fake pagination’ solution
- A node-based solution, creating real pagination where each page is a node in the document tree.
- A “fake dom” solution, keeping the data model hidden and syncing them to the rendered dom.
Let’s go through them one by one and see where all of them fall short.
Client-side canvas painting
Examples: Google Docs, OnlyOffice. The editor owns every pixel. Google famously ditched the DOM in favor of canvas for Docs for the simple reason that they needed to guarantee pixel-perfection across all browsers and devices, and they’re right — canvas is the only way to guarantee pixel perfection (read more about it here). OnlyOffice took the same route, and it’s the foundation of their pitch for print fidelity and clean docx round-trips.
A quick word on wasm, because it tends to get muddled in this conversation: wasm is not a rendering strategy. It’s a way to run a layout engine — often an existing native one — at near-native speed in the browser. You still have to put the pixels somewhere, and that somewhere is a canvas. So a “wasm editor” is a canvas editor with a native layout core, and it inherits every one of the trade-offs below.
Unless you’re Google, you probably won’t want to go down this path. No, really, don’t. Everything the browser gives you for free, you’ll have to rebuild from scratch;
- caret positioning
- selection handling
- arrow navigation And first and foremost, you’ll have an accessibility nightmare on your hands, and if, like us, you want to offer an extensible editor, well we’re just not even sure how that would work.
Server-side rendering streamed to a canvas
Examples: Collabora Online / LibreOffice Online. A different shape of the same trade. A real LibreOffice process opens the document on the server, computes the layout, and streams the result back as image tiles painted onto a canvas. The browser is the remote control; the server is the source of truth. The fidelity is excellent — it’s LibreOffice doing the layout, so of course it is. But extensibility is off the table: you can’t write a plugin against a document your client never actually holds, and the editing loop is only ever as good as the round trip.
Fake pagination solution, using floats to give the impression of pagination
Example: CKEditor 5’s Pagination feature (as of October 2026) There are two flavours here. CKEditor 5’s Pagination feature draws page break lines over one continuous editing surface, showing where the pages will break in the exported PDF or DOCX, which to be honest is not what we’d consider pagination. The other flavour gives you something that looks like pagination, using CSS floats to break the content, but it has its shortcomings. Now, we’re not saying using floats is a bad idea, it’s a fantastic idea, and anyone who took the time to inspect our editor will see we’re doing the exact same thing, but there’s a nuance to it. On its own, using floats is not the problem, but without a pagination engine, the floats have no context, and as such, there are a number of things fake pagination approaches often can’t do:
- Widow/orphan control: you’d expect lines not to be orphaned or widowed (i.e. a single line left alone on a page), but floats do not discriminate;
- Page breaks: with no context of where they’re landing, page breaks don’t break the page. At best you’ll have an indication of where the page break is, but the following content will not be pushed down to the next page;
- Tables will not play nice: floats can only cut tables between rows. If a cell has too much content, it’ll leave a massive gap in the page; add even more content, and you’ll have to scroll in the cell to see its content;
- Table of contents: with no context of where headings or numbered items land, your table of contents won’t be able to figure out which node is on which page (although I could argue with some effort you could run through your content, their position in the dom and their relation to the page break, so this one is not impossible I suppose)
Node-based solution
This was the original approach taken by Umo editor , but they have since backed away from it. The issue with that is that you are mixing the data model with the visual representation of the editor, and this is not a good idea. There are unfortunately no decorations that allow you to wrap multiple nodes in a single decoration, so you’re stuck needing page nodes in your schema, and boy is that a performance nightmare and creates so much overhead if you need to interact with the content. Picture this: you have a 100-page document, push one line of text, and you’ll potentially have to trigger 200 transactions (one to remove content from one page and one to add it to the next one). ProseMirror is fast, but that’s a lot of overhead for a simple operation. Not to mention all other plugins that rely on inspecting transactions (like track changes, legal numbering, etc) that will have to go through all these transactions as a result. This is a no-go for us.
Fake dom solution
We’ve seen this approach, where the ProseMirror DOM is hidden, and there is a two-way binding between ProseMirror and the rendered DOM. To us, this is the wrong approach — it’s messing with selection and caret positioning, and the two-way binding is not performant at all. In our experience, this approach led to broken selection and fell apart after some odd 20 pages.
A pagination engine to the rescue
To us, there was only one way to go: we needed to handle hundreds of pages without breaking a sweat — widow/orphan control, page breaks that actually break the page, and so much more. So we built a pagination engine and mixed it up with floats, amongst other things. And best of all, the ProseMirror content is utterly unaware of any pagination, everything is done in React. Each node and each line is measured, the browser is performant for these operations, and while you may already be screaming ‘BUT WHAT ABOUT FORCED REFLOWS’, we’ve figured it out too to make it performant. Admittedly, we still have a ways to go, there has been a lot of testing involved and we’re sure edge cases will pop up, but we’re confident we’ve got a solid foundation to build on. Just try it out for yourself and let us know what you think. You may notice some lag on documents that run into the hundreds of pages, but Rome wasn’t built in a day either, and there is still room for us to improve. More aggressive caching, better memoization — the path is laid out for us. Oh, and if you’re wondering about line numbering, well that’s just a by-product of the pagination engine, couldn’t do it without it either! And if we’re perfectly honest, we couldn’t have done it without react-prosemirror either, we keep bringing it back, but by jolly are we ever thankful for the team behind it , and if you want to express your gratefulness, make sure to check out their latest project Pitter Patter .
If you’re still considering implementing your own pagination
I hope this post has given you a better understanding of the challenges involved. Prepare to spend a lot of hours going down that rabbit hole. If not, we’re here for you, consider contacting us to try our editor out.