Production recipes
Copyable Vue patterns for invoices, statements, reports, repeating matter, and payment QR codes.
These are application recipes, not extra framework primitives. They compose the
same small PdfView/PdfText surface used by the tested playground documents.
Use this page as a pattern index:
- Repeating header, footer, and page numbers
- Address and key/value blocks
- Invoice lines, totals, and shared table columns
- Dot leaders
- Payment details and QR graphics
- Headings and continuation headers
- Reports with a table of contents
Read Build reusable document components before turning a recipe into a shared component. Exact style values remain in the style reference.
Repeating header, footer, and page numbers
A fixed node repeats when one authored page wraps into multiple output pages.
Keep the page padding large enough that flowing content never overlaps it.
<PdfPage :style="{ paddingBottom: 52, paddingHorizontal: 40, paddingTop: 64 }">
<PdfView fixed :style="{ left: 40, position: 'absolute', right: 40, top: 24 }">
<PdfText>Quarterly statement</PdfText>
</PdfView>
<PdfText
fixed
:render="({ pageNumber, totalPages }) => `Page ${pageNumber} / ${totalPages}`"
:style="{ bottom: 24, position: 'absolute', right: 40 }"
/>
<slot />
</PdfPage>Address or party block
Use a typed child component so the invoice template remains readable.
<script setup lang="ts">
defineProps<{ label: string, lines: string[] }>()
</script>
<template>
<PdfView :style="{ width: 220 }">
<PdfText :style="{ color: '#68736B', fontSize: 8, marginBottom: 6 }">
{{ label }}
</PdfText>
<PdfText v-for="line in lines" :key="line" :style="{ fontSize: 10 }">
{{ line }}
</PdfText>
</PdfView>
</template>Key/value details
Keep widths explicit; PDF rows should not depend on browser table layout.
<PdfView
v-for="entry in details"
:key="entry.label"
:style="{ flexDirection: 'row', marginBottom: 6 }"
>
<PdfText :style="{ color: '#68736B', width: 110 }">{{ entry.label }}</PdfText>
<PdfText :style="{ flex: 1 }">{{ entry.value }}</PdfText>
</PdfView>Invoice lines and totals
Make each line indivisible with wrap="false"; keyed rows then move as a unit
instead of losing a description or amount at a page break.
<PdfView
v-for="line in invoice.lines"
:key="line.id"
:wrap="false"
:style="{ borderBottomColor: '#DDE3DE', borderBottomWidth: 1, flexDirection: 'row', paddingVertical: 10 }"
>
<PdfText :style="{ flex: 1 }">{{ line.description }}</PdfText>
<PdfText :style="{ textAlign: 'right', width: 90 }">{{ money(line.total) }}</PdfText>
</PdfView>
<PdfView :wrap="false" :style="{ marginLeft: 'auto', marginTop: 16, width: 220 }">
<PdfText :style="{ fontWeight: 700, textAlign: 'right' }">Total {{ money(total) }}</PdfText>
</PdfView>Tables with a shared column spec
A table is a column of rows. Give every row the same flexDirection: 'row'
and fixed cell widths, and put those widths in one module. The header and the
body then cannot drift apart when a column changes.
export const columns = {
amount: { align: 'right', width: 86 },
description: { width: 246 },
quantity: { align: 'right', width: 52 },
} as const<PdfView :style="{ flexDirection: 'row', paddingBottom: 8 }" :wrap="false">
<PdfText :style="{ fontSize: 7, width: columns.description.width }">Description</PdfText>
<PdfText :style="{ fontSize: 7, textAlign: 'right', width: columns.quantity.width }">Qty</PdfText>
<PdfText :style="{ fontSize: 7, textAlign: 'right', width: columns.amount.width }">Amount</PdfText>
</PdfView>
<PdfView
v-for="line in invoice.lines"
:key="line.id"
:wrap="false"
:style="{ borderBottomColor: '#DDE3DE', borderBottomWidth: 1, flexDirection: 'row', paddingVertical: 10 }"
>
<PdfText :style="{ flex: 1 }">{{ line.description }}</PdfText>
<PdfText :style="{ textAlign: 'right', width: columns.amount.width }">{{ money(line.total) }}</PdfText>
</PdfView>Keep body cells inside a wrap="false" row so a long line moves as a unit.
Dot leaders for contents and price lists
A dotted rule that stretches between a label and a number is one view with a
dotted bottom border. Wrap each entry in a row with alignItems: 'flex-end'
and let the leader take the remaining space:
<script setup lang="ts">
defineProps<{
baseline?: number
color: string
gap?: number
width?: number
}>()
</script>
<template>
<PdfView
:style="{
borderBottomColor: color,
borderBottomStyle: 'dotted',
borderBottomWidth: width ?? 1,
flex: 1,
marginBottom: baseline ?? 3,
marginHorizontal: gap ?? 7,
}"
/>
</template><PdfView :style="{ alignItems: 'flex-end', flexDirection: 'row' }">
<PdfText>{{ section.title }}</PdfText>
<DotLeader color="#CDD5CF" />
<PdfText>{{ pageNumbers[section.id] ?? '' }}</PdfText>
</PdfView>The baseline offset sits the dots on the text baseline; start at 2 to 4
and adjust against your font size.
Payment details and QR
Generate the payment payload in setup code, turn consecutive dark QR modules
into one SVG path, and keep the payment panel together with wrap="false".
<PdfView :wrap="false" :style="{ flexDirection: 'row', marginTop: 20 }">
<PdfView :style="{ flex: 1 }">
<PdfText>IBAN {{ payment.iban }}</PdfText>
<PdfText>BIC {{ payment.bic }}</PdfText>
<PdfText>Reference {{ invoice.number }}</PdfText>
</PdfView>
<PdfSvg :viewBox="`0 0 ${qrSize} ${qrSize}`" :style="{ height: 72, width: 72 }">
<PdfPath :d="qrPath" fill="#000000" />
</PdfSvg>
</PdfView>Keep a heading with what follows
minPresenceAhead asks pagination to move the heading when too little usable
space remains. Use wrap="false" only when the whole section is genuinely
small enough to fit on a page.
<PdfView :style="{ marginTop: 24 }">
<PdfText :min-presence-ahead="54" :style="{ fontSize: 15, marginBottom: 10 }">
Payment terms
</PdfText>
<PdfText>{{ terms }}</PdfText>
</PdfView>Long rows with continuation headers
Put the header and rows in one wrapping page. The fixed header repeats on every continuation page; page padding reserves its space.
<PdfPage :style="{ paddingHorizontal: 40, paddingTop: 72 }">
<PdfView fixed :style="{ flexDirection: 'row', left: 40, position: 'absolute', right: 40, top: 36 }">
<PdfText :style="{ flex: 1 }">Description</PdfText>
<PdfText :style="{ textAlign: 'right', width: 90 }">Amount</PdfText>
</PdfView>
<InvoiceLine v-for="line in lines" :key="line.id" :line="line" />
</PdfPage>Report sections and a table of contents
Read the page map only where the document needs it. The first pass returns no number, so keep an empty fallback; Nuxt PDF rerenders until the map stabilizes.
<script setup lang="ts">
const pageNumbers = usePdfPageNumbers()
</script>
<template>
<PdfPage>
<PdfLink v-for="section in sections" :key="section.id" :href="`#${section.id}`">
{{ section.title }} ..... {{ pageNumbers[section.id] ?? '' }}
</PdfLink>
</PdfPage>
<PdfPage v-for="section in sections" :key="section.id">
<PdfView :id="section.id" :bookmark="section.title">
<PdfText :style="{ fontSize: 22 }">{{ section.title }}</PdfText>
<PdfText>{{ section.body }}</PdfText>
</PdfView>
</PdfPage>
</template>The links and table-of-contents guide explains convergence and invalid destinations. Use the testing guide to protect document text, links, page counts, and reviewed raster output.