Skip to content

Pagination ​

Uhuu templates paginate with uhuu-components, the React library Uhuu's own templates are built on. You design each page at its print size. Where the length of a part depends on the data, such as a table, a product list or a text body, the library measures that part and continues it on extra pages, so a long payload is never clipped. Every page, including the extra ones, is still your React page: edit dialogs keep working, and page numbers count the finished document.

Every technique on this page has a live example in the component catalog at components.uhuu.io, under Pagination and EditorShell.

Paged.js

Earlier versions of these docs recommended Paged.js. uhuu-components no longer includes it, and new templates should not use it: Paged.js copies the rendered HTML into pages of its own, so React click handlers and edit dialogs do not work on the result. To move a template off Paged.js, see Coming from Paged.js.

Install ​

sh
npm install uhuu-components

The package works with React 18 and 19. Page sizes, bleed and spine widths are in millimetres.

How Pages Are Built ​

A template lists its pages in a templateConfig and renders them with PageEditor. Most pages are fixed: their layout fits one sheet. A page whose content varies in length is a flow page (hasFlow: true). It marks the region that may overflow, and the library continues that region on as many sheets as it needs.

text
Pages in templateConfig        Printed document
  Cover                          1  Cover
  Facts      (hasFlow)           2  Facts
                                 3  Facts, continued
                                 4  Facts, continued
  Contact                        5  Contact

Continuation sheets appear in the editor preview and in the PDF, and page numbers count them. Users do not add, remove or reorder them: they come and go with the content.

jsx
import { useEffect, useState } from 'react'
import { EditorShell } from 'uhuu-components'

const { InteractiveModeProvider, TemplateDataProvider, PageEditor } = EditorShell

const templateConfig = {
  pages: {
    cover: { label: 'Cover', component: CoverPage, strictPosition: 'start' },
    facts: { label: 'Facts', component: FactsPage, hasFlow: true },
    contact: { label: 'Contact', component: ContactPage, strictPosition: 'end' },
  },
  initial: ['cover', 'facts', 'contact'],
}

export default function App() {
  const [payload, setPayload] = useState($uhuu.payload())
  useEffect(() => { $uhuu.listen('payload', (data) => setPayload(data)) }, [])

  if (!payload) return null

  return (
    <InteractiveModeProvider defaultInteractive>
      <TemplateDataProvider payload={payload}>
        <PageEditor templateConfig={templateConfig} pageFormat={{ width: 210, height: 297 }} />
      </TemplateDataProvider>
    </InteractiveModeProvider>
  )
}

PageEditor passes every page component its data and position: payload, pageNum and totalPages (counted over the finished document), and page, which tells a flow page which of its sheets it is rendering.

Only pages with hasFlow: true are measured, so give the flag to pages that contain a flow and to no others. A flow needs PageEditor: on its own, Static.Pagination renders authored sheets one to one and never adds a sheet.

Fixed Pages ​

A fixed page is an ordinary React component laid out at sheet size:

jsx
function CoverPage({ payload }) {
  return (
    <div className="flex h-full flex-col justify-end p-[18mm]">
      <h1 className="text-5xl">{payload.title}</h1>
    </div>
  )
}

A fixed page does not grow: content that does not fit overflows the sheet and can be cut off. When a part of the page can grow, make it a flow page.

Flow Pages ​

Rows and Cards ​

Put the page's header, footer and growing region in Static.FlowPage, and give the region's items to Static.Flow:

jsx
import { Static } from 'uhuu-components'

function ProductsPage({ payload, pageNum, totalPages }) {
  return (
    <Static.FlowPage
      className="p-[18mm]"
      flowAreaClassName="mt-[8mm]"
      header={<h1>Products</h1>}
      footer={<footer>{pageNum} / {totalPages}</footer>}
    >
      <Static.Flow
        id="products"
        items={payload.products}
        getKey={(product) => product.id}
        renderItem={(product) => <ProductRow product={product} />}
      />
    </Static.FlowPage>
  )
}

The header and footer repeat on every sheet; the items fill the space between them, and an item that does not fit moves to the next sheet.

  • id is stable and unique on the page. Two growing regions on one page are two Static.Flows with different ids.
  • getKey returns a stable key for each item.
  • One item is one unit that moves. A table is one item per row, a catalog one item per product, a text one item per paragraph.
  • Render the flow in every case, even when items is empty: an empty flow is one sheet.

Static.FlowPage gives the region the bounded height the measurement needs. To build the page layout yourself, wrap the Static.Flow in a Static.FlowArea whose height is bounded by the page, for example className="flex-1 min-h-0" inside a full-height flex column.

Examples: Flow, Hybrid (fixed and flow pages in one document).

Tables ​

To flow the rows of a real <table>, render the flow as the <tbody> and let each item be its own <tr>. A header row that repeats on every sheet is a group header:

jsx
<Static.FlowPage header={<h1>Facts</h1>}>
  <table className="w-full border-collapse">
    <Static.Flow
      id="facts"
      as="tbody"
      asChild
      items={rows}
      getKey={(row) => row.id}
      getItemGroup={() => ({ key: 'facts', repeatHeader: true })}
      renderGroupHeader={() => <tr><th>Fact</th><th>Value</th></tr>}
      renderItem={(row) => <tr><td>{row.label}</td><td>{row.value}</td></tr>}
    />
  </table>
</Static.FlowPage>

With asChild, the element renderItem returns is the item itself, so table and list CSS (:first-child, dividers) applies to your rows as written. Use a group header rather than a <thead>, and border-collapse: collapse on the table. as="ul" or as="ol" with <li> items works the same way for lists.

Example: Flow Table.

Long Text ​

A flow breaks between items. When one item holds text of unknown length, such as a description, terms or a letter, put that text in a Static.FlowText: the item then splits between the lines of the text when it does not fit, over as many sheets as it needs.

jsx
<Static.Flow
  id="facts"
  as="tbody"
  asChild
  items={rows}
  getKey={(row) => row.id}
  renderItem={(row, index, fragment) => (
    <tr>
      {/* Continuations keep the label cell, empty, so the text keeps its width. */}
      <th>{fragment.split?.part ? null : row.label}</th>
      <td><Static.FlowText>{row.value}</Static.FlowText></td>
    </tr>
  )}
/>
  • An item splits only when it holds exactly one FlowText and does not fit whole. An item that fits is placed whole.
  • A FlowText holds a string or inline content: text, span, strong, em, a, br and similar. Block elements inside it are not split.
  • Each piece renders the whole item again, with the part of the text that belongs on that sheet. fragment.split tells renderItem which piece it is rendering.
  • Keep the text the same width on every piece: hide a label's content on continuations, not its cell.
  • Put an Editable around the FlowText, not inside it. It then opens the same dialog on every piece.

Example: Flow Long Text.

HTML and Markdown Bodies ​

For a text body (Markdown, CMS rich text or any other HTML), Static.FlowDocument is a complete flow page. Convert the body to HTML and pass it in:

jsx
import { marked } from 'marked'
import { Static } from 'uhuu-components'

function ArticlePage({ payload, pageNum, totalPages }) {
  return (
    <Static.FlowDocument
      html={marked.parse(payload.body ?? '')}
      className="p-[18mm]"
      header={<h1>{payload.title}</h1>}
      footer={<footer>{pageNum} / {totalPages}</footer>}
      editable={{ path: 'body', type: 'markdown' }}
    />
  )
}

FlowDocument splits the HTML into its top-level blocks and flows them. Paragraphs and headings split between their lines; lists, tables, figures and quotes stay whole. Headings stay with the block that follows them. The HTML is sanitized before it renders, and editable makes the whole body open one edit dialog. The Markdown converter (marked here) is your template's own dependency.

When each Markdown block must render as a live React element, for example through react-markdown with custom components, use Static.markdownToFlowItems(markdown) to get the blocks and render them with Static.Flow.

Examples: HTML Flow, Markdown Flow.

Columns ​

To set part of a flow side by side, such as two columns under a full-width heading, use Static.FlowColumns. It takes the same props as Static.Flow, plus a layout that places each item. Each column continues onto the next sheet on its own:

jsx
<Static.FlowPage header={<h1>Report</h1>}>
  <Static.FlowColumns
    id="body"
    items={blocks}
    getKey={(block) => block.id}
    renderItem={(block) => <Block block={block} />}
    layout={[
      { kind: 'item', index: 0 },                  // full width
      { kind: 'columns', id: 'pair', columns: [
        { id: 'left', indexes: [1, 2] },          // items 1 and 2, top to bottom
        { id: 'right', indexes: [3] },
      ] },
      { kind: 'item', index: 4 },                  // full width, under the longer column
    ]}
    getColumnGroupProps={() => ({ style: { columnGap: '6mm' } })}
  />
</Static.FlowPage>
  • layout names every item index once, in reading order. An item runs full width; a columns group lays its columns side by side, at equal widths unless getColumnProps sets a column's className or style. Columns do not nest.
  • A column group starts in the room left on a sheet. When its first items do not fit only because that room is short, the whole group moves to the next sheet.
  • Give column groups and columns no vertical margin, padding, border, minimum or maximum height, or row gap: the measurement cannot see them. Space the items instead, with getColumnItemProps.
  • Items move whole. A Static.FlowText does not split inside a column, and breakReserve is not kept.

Example: Flow Columns.

A host that shows the document on screen can offer a Print switch: on, the pages as the PDF prints them; off, the same pages for reading on screen. PageEditor takes it as view, "print" (the default) or "screen". The PDF renderer always draws "print", so the PDF is the same whichever view the host shows:

jsx
<PageEditor templateConfig={templateConfig} view={printSwitch ? 'print' : 'screen'} />

The view alone changes nothing. A template decides what differs on screen, and the usual difference is a table: in print its rows continue on the next sheet under a repeated header; on screen a reader wants it whole. Keep the table flow as it is and give its group growSheet: true:

jsx
<Static.Flow
  id="facts" as="tbody" asChild items={rows} getKey={(row) => row.id}
  getItemGroup={() => ({ key: 'facts', repeatHeader: true, growSheet: true })}
  renderGroupHeader={() => <tr><th>Fact</th><th>Value</th></tr>}
  renderItem={(row) => <tr><td>{row.label}</td><td>{row.value}</td></tr>}
/>

Print ignores growSheet. In the screen view the group is one unit, with its header once and every row on one sheet:

  • when it fits the room left on the sheet, it stays there;
  • when it fits a fresh sheet, it moves there whole, together with a heading kept with it by keepWithNext;
  • when it is taller than any sheet, it starts right under what precedes it, and its sheet is drawn taller at the same width, so the whole table shows and the footer follows it.

A block that is one item anyway, such as a chart or an image, takes the same flag as item meta, getItemMeta={() => ({ growSheet: true })}. Where print would scale the item onto a sheet of its own, the screen view draws it whole on a taller sheet. Items and groups without growSheet keep the print rules in both views.

  • A fixed-height area. Static.FlowPage grows with its sheet by itself. A Static.FlowArea of a fixed height must add the growth, which the sheet carries as the CSS variable --uhuu-sheet-growth: style={{ height: 'calc(180mm + var(--uhuu-sheet-growth, 0px))' }}. Without it, the table draws over what follows the area, and the browser console says so in development.
  • HTML bodies. Static.FlowDocument keeps every HTML table whole on screen with metaDefaults={{ table: { growSheet: true } }}.
  • Columns. In Static.FlowColumns, growSheet applies to items and groups outside the column groups.
  • Anything else. A page reads the view with Static.useView(), which returns "print" or "screen". CSS can read it from <html data-uhuu-view="screen">: html[data-uhuu-view="screen"] .print-only { display: none }.

view, Static.useView() and growSheet need uhuu-components 0.4.3. data-uhuu-view and the console note about a fixed-height area need 0.4.4.

Example: Flow Table → Screen View.

Page Breaks ​

Breaks are set per item with getItemMeta, or per item type with metaDefaults:

jsx
<Static.Flow
  id="sections"
  items={blocks}
  getKey={(block) => block.id}
  metaDefaults={{ heading: { keepWithNext: 2 } }}
  getItemMeta={(block) => ({ breakBefore: block.startsNewPage })}
  renderItem={(block) => <Block block={block} />}
/>

metaDefaults keys match each item's type field (pass getItemType when your items name their type differently). getItemMeta wins over the defaults.

ToItem meta
Start an item on a new sheetbreakBefore: true
Start the item after it on a new sheetbreakAfter: true
Keep a heading with what follows itkeepWithNext: true (one item) or a number of items
Move a section to the next sheet as a whole when it does not fitavoidBreakInside: true and the same groupKey on its items
Keep an item with a FlowText wholeavoidBreakInside: true
Set the fewest lines left at the bottom of a sheet, or carried to the top of the nextorphans, widows (2 by default)
Keep an item whole on screen, growing its sheet (the screen view only)growSheet: true

In HTML for FlowDocument, write <!-- page-break-before --> before a block (the comment survives Markdown conversion), or put data-flow-break-before or data-flow-break-after on an element.

Page Numbers, Headers and Footers ​

Page components receive pageNum and totalPages, counted over the finished document with every continuation sheet. Print them wherever the design wants them.

Unless told otherwise, PageEditor prints its own page number ("Page 3 / 18") over every page. A template that numbers its pages replaces it or turns it off:

jsx
<PageEditor
  templateConfig={templateConfig}
  renderOverlay={({ pageNum, totalPages }) => <footer className="page-number">{pageNum} / {totalPages}</footer>}
/>

<PageEditor templateConfig={templateConfig} renderOverlay={false} />

A flow page knows which of its sheets it renders: page.virtualPageIndex is 0 on its first sheet, and page.virtualPageCount is how many sheets it has. Use them for a running title on continuation sheets, or a "continued" note on every sheet but the last:

jsx
function FactsPage({ page, pageNum, totalPages }) {
  const first = page.virtualPageIndex === 0
  const last = page.virtualPageIndex === page.virtualPageCount - 1

  return (
    <Static.FlowPage
      className="p-[18mm]"
      measureContinuation
      breakReserve="12mm"
      header={first ? <h1>Property facts</h1> : <p>Property facts (continued)</p>}
      footer={
        <footer className="h-[8mm]">
          {last ? '' : 'Continued on the next page. '}
          {pageNum} / {totalPages}
        </footer>
      }
    >
      <Static.Flow id="facts" /* … */ />
    </Static.FlowPage>
  )
}
  • measureContinuation: continuation sheets have a different amount of room than the first sheet (here, the large title is on the first sheet only). Without it, every sheet is planned with the first sheet's room.
  • breakReserve: keeps room free at the bottom of every sheet the flow continues from, and none on the sheet where it ends.
  • Chrome that only some sheets show, such as the "continued" note, needs a slot of the same height on every sheet (the footer above is 8mm on every sheet). Otherwise the room it takes is not planned for.

Page Size, Margins and Bleed ​

  • Size is pageFormat on PageEditor, in millimetres: { width: 210, height: 297 } for A4, { width: 148, height: 210 } for A5.
  • Margins are the padding of your page component (p-[18mm], or px-[20mm] pt-[24mm] pb-[12mm]).
  • Bleed is pageFormat.bleed, in millimetres. For print products that need different bleed or pages per product component, PageEditor takes pageFilter and printConfigs; see Print Products.
  • pageFormat.preview: 'two_pages' shows the editor preview as spreads.

Edit Dialogs on Paginated Pages ​

Every sheet is rendered by React, so edit dialogs are bound the same way on every page:

jsx
import { Editable, getDialogProps } from 'uhuu-components'

<Editable dialog={{ path: 'title', type: 'text', value: title }}>{title}</Editable>

<p {...getDialogProps({ dialog: { path: 'intro', type: 'textarea', value: intro } })}>
  {intro}
</p>

In the editor, every binding shows a marker and, on hover, a dashed frame, without changing the layout. Neither appears in the PDF. See Edit Dialogs for the dialog types.

Before the PDF Is Printed ​

Measuring takes a moment, and images, web fonts and a BrandKit change sizes while they load. The document reports its state on the <html> element:

  • data-uhuu-pagination="measuring": pages are still being measured, or something they depend on is loading.
  • data-uhuu-pagination="settled": the pages are final and ready to print.
  • data-uhuu-pagination="error": a flow region is invalid. The region names the problem in data-uhuu-flow-error (see Troubleshooting).

Beside it, data-uhuu-view says which view the pages were drawn for: "print" in every PDF, whatever view was asked for, or "screen". It needs uhuu-components 0.4.4.

The library waits for images in flow pages, web fonts and BrandKit fonts by itself. If a page depends on work it cannot see, such as a map that draws its tiles, a chart that lays itself out or data the page fetches, hold the signal until that work is done:

jsx
function LocationMap({ center }) {
  const [loaded, setLoaded] = useState(false)
  Static.usePaginationHold(!loaded, 'Location map')
  return <Map center={center} onLoad={() => setLoaded(true)} />
}

A hold, like a pending image, lasts at most 10 seconds. After that, the console names the hold and the document stops waiting, so a map that never loads cannot keep a document from printing.

Give images and media a fixed size, so a late load does not move items between sheets.

Example: Template Hold.

Coming from Paged.js ​

Paged.js laid out a whole HTML document with CSS Paged Media. With uhuu-components, you choose which parts of the document grow, and everything else stays an authored page:

Paged.js / CSS Paged Mediauhuu-components
@page { size: A5 }pageFormat={{ width: 148, height: 210 }} on PageEditor
@page { margin: 30mm 10mm }Padding on the page component
counter(page), counter(pages)The pageNum and totalPages props, or renderOverlay
Margin boxes (@top-center, @bottom-left)header and footer of Static.FlowPage, or renderOverlay
@page :firstpage.virtualPageIndex === 0, or a separate cover page in templateConfig
@page :left, @page :rightpageNum % 2 === 0 (left) on the page component
@page :blankNo generated blank pages: add an authored page where one is needed
break-before: page, page-break-before: alwaysbreakBefore: true; in HTML, <!-- page-break-before --> or data-flow-break-before
break-after: pagebreakAfter: true
break-after: avoid on headingskeepWithNext
break-inside: avoidavoidBreakInside: true (with a shared groupKey for a section)
orphans, widowsorphans, widows in the item meta, or the same CSS on the FlowText
column-count, multi-column layoutStatic.FlowColumns
A repeating <thead>A group header with repeatHeader: true
CSS counters for figures and sectionsNumbers from your data, such as the item's index
A data-uhuu attribute and click listeners added after the Paged.js renderEditable or getDialogProps, which stay live on every sheet

To migrate a page:

  1. Keep the fixed pages as they are, each as a page in templateConfig.
  2. Find the part of each remaining page that varies in length, and turn it into an array of items: rows, cards or blocks. For an HTML or Markdown body, use Static.FlowDocument.
  3. Render that part with Static.FlowPage and Static.Flow, and add hasFlow: true to the page.
  4. Add keepWithNext to headings, and put text of unknown length in a Static.FlowText.
  5. Number the pages with pageNum and totalPages, and remove the @page rules and the Paged.js script.
  6. Test with a short payload, a very long one, and edit clicks on every sheet.

Number figures and sections from your data rather than with CSS counters: the library measures pages in a hidden copy that is part of the document, so a CSS counter counts its elements too.

Limits ​

The library splits between the items you give it, and inside a FlowText between lines. It does not:

  • split other content: a table row, image or block without a FlowText moves whole;
  • render CSS margin boxes or @page rules;
  • insert a fixed page between two sheets of the same flow. To place one there, split the content into two flow pages with the fixed page between them.

An item taller than the whole region cannot move anywhere better. It gets a sheet of its own and prints scaled down to fit, so nothing is clipped. The editor outlines it, and the console names it, so you can split it or put its text in a FlowText. In the screen view, an item or group with growSheet is drawn whole on a taller sheet instead, without the outline. The PDF always follows the print rules.

Troubleshooting ​

When the document stays at measuring or reads error, the affected flow region or page carries data-uhuu-flow-error, and the browser console names the page:

ValueFix
missing-flow-regionA hasFlow page needs a Static.FlowPage, Static.FlowDocument or Static.FlowArea in every branch it renders.
missing-flowRender one Static.Flow in the area, even while its items are empty.
missing-flow-id, duplicate-flow-idGive each flow on a page its own stable id.
multiple-flows-in-areaPut each flow in an area of its own.
unmeasurable-flow-areaGive the area a height bounded by the page (Static.FlowPage does this).
unstacked-flow-itemsStack items vertically: no floats, grid or flex row around the flow. For columns, use Static.FlowColumns.
A message naming a Static.FlowColumns column groupRemove vertical margin, padding, borders, minimum or maximum height and row gap from column groups and columns; space the items instead.
unmarked-flow-itemWith asChild, a custom row component must pass the props it receives to its root element.
missing-continuation-flowWith measureContinuation, render the same flow on continuation sheets too.

Examples ​

All examples run in the component catalog at components.uhuu.io:

Public developer documentation for Uhuu.