Skip to main content

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:

Read Build reusable document components before turning a recipe into a shared component. Exact style values remain in the style reference.

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.

vue
<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.

pdfs/components/PartyBlock.vue
<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.

vue
<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.

vue
<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.

pdfs/components/columns.ts
export const columns = {
  amount: { align: 'right', width: 86 },
  description: { width: 246 },
  quantity: { align: 'right', width: 52 },
} as const
vue
<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:

pdfs/components/common/DotLeader.vue
<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>
vue
<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".

vue
<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.

vue
<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.

vue
<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.

vue
<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.