A word processor shows you where the page ends. A contenteditabledoes not — it is one continuous column of text that happens to scroll. For anyone drafting a contract, a report, or anything that will be printed or signed, that gap is the difference between “a text box” and “a document editor”.
This is how to project a continuous DOM onto real paper geometry, and the measurement mistake that will silently let your pages overflow.
Rule one: never mutate the content
The tempting implementation is to slice content into per-page containers. Resist it. The moment page boundaries become elements in the document, three things break:
- Every keystroke can reflow the content, so those containers must be rebuilt constantly.
- Your serialized HTML now carries layout artefacts that were never part of the document.
- Undo, selection across boundaries, and collaborative sync all have to understand the fake structure.
Pagination is a view concern. Draw the boundaries in an overlay that is contenteditable="false", aria-hidden, and pointer-events: none. The test to hold yourself to: the HTML you save with page view on should be byte-for-byte identical to the HTML you save with it off.
Page geometry at 96 dpi
CSS defines one inch as 96 reference pixels, which is the basis the browser’s own print pipeline uses. That gives you exact page sizes to lay out against:
| Paper | Inches | CSS pixels |
|---|---|---|
| A4 | 8.27 × 11.69 | 794 × 1123 |
| Letter | 8.5 × 11 | 816 × 1056 |
| Legal | 8.5 × 14 | 816 × 1344 |
The number you actually paginate against is not the page height but the usable content height:
contentHeight = pageHeight - marginTop - marginBottom
contentWidth = pageWidth - marginLeft - marginRight
// A4 with 1in margins:
// 1123 - 96 - 96 = 931px of text per pageLandscape is a swap of width and height before the margin subtraction — do it in that order, or your margins end up applied to the wrong axis.
Finding the breaks
Walk the editable’s top-level blocks, accumulating height. When the next block would push past contentHeight, the page ends before that block. This is block-level pagination: a paragraph, table row, or image never gets sliced through the middle, which matches what the print pipeline does with unbreakable content anyway.
Two cases need explicit handling:
- Forced breaks.If the document contains an explicit page-break element, start a new page there regardless of remaining space — the same behaviour the export path uses.
- Blocks taller than a page. A tall image or long table cannot fit anywhere. Advance whole pages past it rather than cascading a break at every subsequent block.
The measurement trap: offsetHeight ignores margins
This is the bug worth the article. The obvious way to measure a block is:
// WRONG - silently overflows pages
var top = el.offsetTop;
var bottom = top + el.offsetHeight;offsetHeightis a border-box measurement. It excludes margins — and in normal flow, the gap between two blocks is exactly that margin (after collapsing). Sum offsetHeight down a document and you undercount the real flow by every margin between every block.
The symptom is subtle and easy to miss: pagination looks broadly right, but pages quietly run past their limit. In our case, pages measured 945–948px against a 931px page — a ~2% overflow that a casual look would never catch, and that would misalign every page boundary against the printed output.
The fix is to stop measuring the block and start measuring the flow: a block’s effective bottom is where the next block begins.
// RIGHT - captures the collapsed margin gap
var top = blocks[i].offsetTop;
var bottom = (i + 1 < blocks.length)
? blocks[i + 1].offsetTop // next block's top includes the margin
: top + blocks[i].offsetHeight; // last block: fall back to its own boxAfter that change every page measured at or under the limit. If you build this yourself, assert it: compute the span between consecutive boundaries and check none exceeds contentHeight. It is a two-line test that catches an entire class of error.
Coordinate systems will bite you twice
offsetTop is relative to offsetParent, not to the editable. Two consequences:
- The first block does notstart at 0 — it starts at the editable’s content-box origin. Initialise your running page position to the first block’s
offsetTop, not zero, or page one is short by the top margin. - Pin the overlay to the editable’s own offset, then convert: an overlay-local y is
absoluteOffset - editable.offsetTop. Mixing the two spaces produces boundaries that look plausible and drift down the document.
Boundaries, not gaps
Real paper has a physical gap between sheets. A continuous editable does not, and inserting one means mutating layout. Draw a rule across the columnat the boundary instead, with a page number beside it. This is what Word’s page-break line and CKEditor’s pagination both do, and it has the advantage of never covering the text underneath — which a filled band would.
Repeating headers and footers belong in the same overlay, positioned in the margin bands, with {page} and {total} placeholders expanded per page.
Make the screen agree with the export
The final step is the one most likely to be skipped: the page setup that drives the on-screen view must be the same page setup that drives PDF and Word export. If page view says A4 landscape and your PDF export is hardcoded to US Letter portrait, you have built a preview that lies.
Store page setup on the document, and have all three surfaces read it. Then verify all three — not just the one you were working on.
Checklist
- Overlay only — saved HTML identical with page view on and off.
- Geometry at 96dpi; swap axes for landscape before subtracting margins.
- Break on block boundaries; honour forced breaks; handle over-tall blocks.
- Measure flow via the next block’s
offsetTop, never summedoffsetHeight. - Start the first page at the first block’s offset, not zero.
- Assert no page span exceeds the content height.
- One page-setup source shared by view, PDF and Word.
Paginated page view ships in RichTextEditor — see the documentation or the live demo.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.