PDFreact-print-pdf
DocsBrowser-only alpha

Financial summary report

A quarterly summary with KPI cards, three charts (vector + raster fallback), and a transactions table that paginates with a repeating header.

Edit this page

Live financial summary preview\u00b7 A4

StackBlitzGitHub Workshop
Click Export PDF to download a real vector PDF. The on-screen pixels match the PDF output exactly.
// Multi-section dashboard fixture. Demonstrates a real-world report layout// that paginates cleanly: KPI grid + charts + a long transactions table// with a repeating column header.import {  Bar,  BarChart,  CartesianGrid,  Cell,  Line,  LineChart,  Pie,  PieChart,  Tooltip,  XAxis,  YAxis,} from "recharts";const PALETTE = ["#2563eb", "#10b981", "#f59e0b", "#8b5cf6", "#ef4444", "#06b6d4"];// 12 months of revenue + cost so we can show a stacked-feeling chart.const monthlyPnl = [  { m: "Jul", revenue: 21400, cost: 14600 },  { m: "Aug", revenue: 24800, cost: 16100 },  { m: "Sep", revenue: 27300, cost: 17400 },  { m: "Oct", revenue: 29900, cost: 18800 },  { m: "Nov", revenue: 32400, cost: 19500 },  { m: "Dec", revenue: 36700, cost: 21100 },  { m: "Jan", revenue: 28400, cost: 17800 },  { m: "Feb", revenue: 32100, cost: 19200 },  { m: "Mar", revenue: 29800, cost: 18700 },  { m: "Apr", revenue: 38200, cost: 22400 },  { m: "May", revenue: 45100, cost: 25100 },  { m: "Jun", revenue: 48200, cost: 26300 },];const topProducts = [  { name: "Indie license", sales: 18420 },  { name: "Pro license", sales: 14210 },  { name: "Onboarding", sales: 9180 },  { name: "Enterprise", sales: 7340 },  { name: "Add-ons", sales: 4120 },];const teamShare = [  { name: "Engineering", value: 42 },  { name: "Design", value: 18 },  { name: "Product", value: 14 },  { name: "Sales", value: 16 },  { name: "Support", value: 10 },];// 28 transactions so the table comfortably overflows onto a second page.const txns = Array.from({ length: 28 }, (_, i) => {  const customers = [    "Acme Corp",    "Globex",    "Initech",    "Soylent",    "Umbrella",    "Wayne Ent.",    "Stark Ind.",    "Hooli",    "Pied Piper",    "Wonka",  ];  const types = ["Subscription", "One-time", "Refund", "Upgrade"];  const c = customers[i % customers.length]!;  const t = types[i % types.length]!;  const amount = (Math.round(Math.sin(i * 1.7) * 480) + 720) * (t === "Refund" ? -1 : 1);  const day = (i % 28) + 1;  return {    id: `TXN-${String(2400 + i).padStart(5, "0")}`,    date: `2026-06-${String(day).padStart(2, "0")}`,    customer: c,    type: t,    amount,  };});function Card({  title,  subtitle,  children,}: {  title: string;  subtitle?: string;  children: React.ReactNode;}) {  return (    <div      className="rounded-md border border-gray-200 bg-white p-4"      style={{ breakInside: "avoid" }}    >      <div className="mb-3">        <div className="text-xs font-medium uppercase tracking-wide text-gray-500">{title}</div>        {subtitle && <div className="text-sm text-gray-900">{subtitle}</div>}      </div>      {children}    </div>  );}const tick = { fill: "#6b7280", fontSize: 11 };const grid = { stroke: "#e5e7eb" };export function DashboardFixture() {  return (    <article className="flex flex-col gap-6 p-8 text-gray-900">      <header className="flex items-end justify-between border-b border-gray-200 pb-4">        <div>          <div className="text-xs font-medium uppercase tracking-wide text-gray-500">            Operations dashboard          </div>          <h1 className="mt-1 text-2xl font-semibold">June 2026 — Monthly review</h1>          <p className="mt-1 text-sm text-gray-500">            Prepared for the leadership team · Confidential          </p>        </div>        <div className="text-right text-xs text-gray-500">          Period          <div className="text-sm font-medium text-gray-900">Jun 1 — Jun 30, 2026</div>        </div>      </header>      {/* KPI grid */}      <div className="grid grid-cols-4 gap-4">        {[          { label: "MRR", value: "$48.2k", delta: "+12.4%", color: "text-emerald-600" },          { label: "Net new ARR", value: "$8.1k", delta: "+22.1%", color: "text-emerald-600" },          { label: "Churn rate", value: "1.4%", delta: "−0.3pt", color: "text-emerald-600" },          { label: "NPS", value: "62", delta: "+4", color: "text-emerald-600" },        ].map((k) => (          <div            key={k.label}            className="rounded-md border border-gray-200 bg-white p-4"            style={{ breakInside: "avoid" }}          >            <div className="text-xs font-medium uppercase tracking-wide text-gray-500">              {k.label}            </div>            <div className="mt-1 text-2xl font-semibold tracking-tight">{k.value}</div>            <div className={`mt-1 text-xs font-medium ${k.color}`}>{k.delta} MoM</div>          </div>        ))}      </div>      {/* P&L line */}      <Card title="Revenue vs cost" subtitle="Last 12 months">        <LineChart width={654} height={220} data={monthlyPnl}>          <CartesianGrid strokeDasharray="3 3" {...grid} />          <XAxis dataKey="m" tick={tick} stroke="#9ca3af" />          <YAxis tick={tick} stroke="#9ca3af" tickFormatter={(v) => `$${v / 1000}k`} />          <Tooltip />          <Line            type="monotone"            dataKey="revenue"            stroke={PALETTE[0]}            strokeWidth={2}            dot={{ r: 2 }}            name="Revenue"            isAnimationActive={false}          />          <Line            type="monotone"            dataKey="cost"            stroke={PALETTE[2]}            strokeWidth={2}            dot={{ r: 2 }}            name="Cost"            isAnimationActive={false}          />        </LineChart>        <div className="mt-2 flex gap-4 text-xs text-gray-600">          <span className="inline-flex items-center gap-2">            <span              className="inline-block size-2 rounded-full"              style={{ backgroundColor: PALETTE[0] }}            />            Revenue          </span>          <span className="inline-flex items-center gap-2">            <span              className="inline-block size-2 rounded-full"              style={{ backgroundColor: PALETTE[2] }}            />            Cost          </span>        </div>      </Card>      {/* Two-column: top products + team share */}      <div className="grid grid-cols-2 gap-4">        <Card title="Top products" subtitle="By gross sales, USD">          <BarChart width={304} height={200} data={topProducts} layout="vertical">            <CartesianGrid strokeDasharray="3 3" {...grid} />            <XAxis              type="number"              tick={tick}              stroke="#9ca3af"              tickFormatter={(v) => `$${v / 1000}k`}            />            <YAxis dataKey="name" type="category" tick={tick} stroke="#9ca3af" width={88} />            <Tooltip />            <Bar              dataKey="sales"              fill={PALETTE[1]}              radius={[0, 4, 4, 0]}              isAnimationActive={false}            />          </BarChart>        </Card>        <Card title="Spend by team" subtitle="% of total OpEx">          <div className="flex items-center gap-4">            <PieChart width={180} height={200}>              <Pie                data={teamShare}                dataKey="value"                nameKey="name"                outerRadius={80}                innerRadius={40}                paddingAngle={2}                isAnimationActive={false}              >                {teamShare.map((s, i) => (                  <Cell key={s.name} fill={PALETTE[i % PALETTE.length]} />                ))}              </Pie>            </PieChart>            <ul className="flex-1 space-y-1.5 text-xs">              {teamShare.map((s, i) => (                <li key={s.name} className="flex items-center justify-between gap-3">                  <span className="inline-flex items-center gap-2 text-gray-700 whitespace-nowrap">                    <span                      className="inline-block size-2 rounded-full"                      style={{ backgroundColor: PALETTE[i % PALETTE.length] }}                    />                    {s.name}                  </span>                  <span className="tabular-nums text-gray-500 whitespace-nowrap">{s.value}%</span>                </li>              ))}            </ul>          </div>        </Card>      </div>      {/* Long table — designed to paginate. We deliberately do NOT wrap          the table in a bordered card: that would create a single tall          rect primitive that can't split across page boundaries, leaving          a blank page before the table. The table's own row-level          borders provide enough visual structure. */}      <section>        <div className="mb-3">          <div className="text-xs font-medium uppercase tracking-wide text-gray-500">            Recent transactions          </div>          <div className="text-sm text-gray-900">Last 28 ledger entries</div>        </div>        <table className="w-full text-sm">          <thead data-print-repeat>            <tr className="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500">              <th className="py-2 text-left font-medium">ID</th>              <th className="py-2 text-left font-medium">Date</th>              <th className="py-2 text-left font-medium">Customer</th>              <th className="py-2 text-left font-medium">Type</th>              <th className="py-2 text-right font-medium">Amount</th>            </tr>          </thead>          <tbody>            {txns.map((t) => (              <tr key={t.id} className="border-b border-gray-100 last:border-b-0">                <td className="py-2 font-mono text-xs text-gray-500">{t.id}</td>                <td className="py-2 text-gray-700">{t.date}</td>                <td className="py-2 text-gray-900">{t.customer}</td>                <td className="py-2 text-gray-700">{t.type}</td>                <td                  className={`py-2 text-right tabular-nums ${                    t.amount < 0 ? "text-red-600" : "text-gray-900"                  }`}                >                  {t.amount < 0 ? "−" : ""}${Math.abs(t.amount).toLocaleString()}                </td>              </tr>            ))}          </tbody>        </table>      </section>      <section className="mt-2 rounded-lg border border-gray-200 bg-white p-4">        <h3 className="text-sm font-semibold text-gray-900">After-table summary</h3>        <p className="mt-2 text-xs leading-5 text-gray-600">          This block sits AFTER the long table. In v0.1 the repeat-band reserve continued past the          table's scope, leaving a phantom 28-pixel-tall gap at the top of every page that contained          this paragraph (or pushed it onto a new page entirely). v0.2 ties the reservation to the          parent <code>&lt;table&gt;</code>'s bottom, so this section can sit flush against the          table on whichever page the table happened to end on.        </p>      </section>      <p className="mt-2 text-xs text-gray-500">        Generated by react-print-pdf. Data shown is illustrative; figures do not reflect any real        company.      </p>    </article>  );}
Live <PrintPreview> · A4Open in new tab
Pages are rendered inline as scaled DOM — no PDF round-trip. The same paginator drives exportToPDF, so the page breaks you see here are the page breaks you'll get in the file.

What you're looking at

The report that lands in a board pack or an investor update. KPI cards across the top, three charts (revenue trend, top products bar, team-share donut), then a paginated transactions section.

This is the example that exercises the mixed vector / raster strategy hardest:

  • KPI cards. Vector text, vector borders, vector backgrounds. The number you care about is selectable in the PDF.
  • Recharts SVG charts. Recharts renders inline <svg> which the walker captures as a high-DPI PNG raster. You get the visual fidelity without writing a PDF chart engine.
  • Transactions table. Vector text, vector borders, repeating column header across pages.
  • Section dividers. Plain <hr> and Tailwind borders. Vector, sharp at any zoom.

The boundary lands where it should: numbers stay selectable; pixel-perfect chart rendering becomes a high-DPI image. No invisible vector text painted underneath the chart trying to mimic axis labels (we considered it; it isn't worth the complexity).

The exporter call

The chart section needs slightly higher raster DPI than the default. Otherwise zooming into a printed copy reveals the chart edges as soft instead of crisp:

const exporter = usePDFExport({
  fileName: `q2-summary-${year}.pdf`,
  paperSize: "A4",
  margins: { top: 56, right: 48, bottom: 48, left: 48 },
  rasterPixelRatio: 3,                // \u2190 charts at 3\u00d7 sharpness
  header: ({ pageNumber, totalPages }) => (
    <ReportHeader period="Q2 2026" page={pageNumber} of={totalPages} />
  ),
  headerHeight: 40,
});

rasterPixelRatio only affects auto-raster regions (charts, shadows, transforms). It doesn't bloat vector content.

The component

Full source on GitHub \u2014 it's the longest fixture in the workshop because it actually demonstrates four sections of a real report.

The shape:

export function FinancialSummary({ data }) {
  return (
    <article className="space-y-8">
      {/* 1. KPI grid \u2014 four boxes across, vector text */}
      <section className="grid grid-cols-4 gap-3">
        <Kpi label="Revenue" value="$394.3K" delta="+18%" />
        <Kpi label="Net margin" value="38.2%" delta="+2.1pp" />
        <Kpi label="ARR" value="$1.84M" delta="+12%" />
        <Kpi label="Churn" value="2.4%" delta="-0.3pp" />
      </section>

      {/* 2. Charts \u2014 inline SVG from recharts, auto-rasterized */}
      <section className="grid grid-cols-12 gap-3">
        <div className="col-span-7 rounded-md border border-gray-200 p-4">
          <h3>Revenue trend</h3>
          <BarChart data={monthlyPnl}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey="m" /> <YAxis />
            <Bar dataKey="revenue" fill="#2563eb" />
            <Bar dataKey="cost" fill="#94a3b8" />
          </BarChart>
        </div>
        <div className="col-span-5 rounded-md border border-gray-200 p-4">
          <h3>Top products</h3>
          <BarChart layout="vertical" data={topProducts}>
            <XAxis type="number" hide /> <YAxis dataKey="name" type="category" />
            <Bar dataKey="sales">
              {topProducts.map((_, i) => <Cell key={i} fill={PALETTE[i]} />)}
            </Bar>
          </BarChart>
        </div>
      </section>

      {/* 3. Commentary block \u2014 vector body text */}
      <section className="rounded-md border border-gray-900 bg-gray-50 p-5">
        <h3 className="text-sm font-semibold uppercase tracking-wide">
          Quarter commentary
        </h3>
        <p className="mt-2 text-sm leading-6">
          Revenue grew 18% QoQ driven by enterprise upgrades \u2026
        </p>
      </section>

      {/* 4. Transactions table \u2014 paginates with repeating header */}
      <section>
        <h2>Transactions \u2014 Q2 2026</h2>
        <table className="w-full text-xs">
          <thead data-print-repeat>
            <tr><th>ID</th><th>Date</th><th>Customer</th><th>Type</th><th>Amount</th></tr>
          </thead>
          <tbody>
            {data.txns.map(t => <TxnRow key={t.id} t={t} />)}
          </tbody>
        </table>
      </section>
    </article>
  );
}

Why the choices look the way they do

Three charts, three layouts

A 12-column grid (grid-cols-12) with a 7/5 split for the two top charts, then a full-width donut row beneath. This mirrors what board packs actually use. Don't bother with a 4-column grid for charts \u2014 charts need real estate to be readable, especially after rasterization.

border on chart wrappers, not shadow

We deliberately use a flat border border-gray-200 instead of a shadow-sm around each chart card. Shadows are a raster boundary \u2014 anything inside a shadow-wrapped card gets captured as one big PNG, not just the chart. Using a border keeps the card title and chart as separate vector / raster regions, and the title stays selectable in the PDF.

KPI delta colors are vector

Red for negative, green for positive, gray for neutral \u2014 all driven by Tailwind utility classes. The walker reads the computed color from each KPI card and emits vector text with that color. No raster needed.

Commentary card has a border border-gray-900

Bold border = "this is the part executives actually read." A subtle border or no border would lose the visual hierarchy in print. The commentary is the report \u2014 the numbers and charts are evidence.

When NOT to use this shape

If your report is 20+ pages of dense tables and footnotes, this layout doesn't scale. KPI dashboards live at the front; the back half should be transactions + appendices. Use break-before: page to separate the dashboard front-half from the data-dense back-half so readers can flip the binder open at either.

Variations

  • Replace recharts with Visx, Victory, Chart.js, etc. The library doesn't care which chart engine emits SVG. Any inline <svg> becomes a raster region automatically.
  • Add commentary per chart. Two-column row, chart on the left, commentary on the right. Keep the chart's wrapper <div> simple so the raster boundary stays tight.
  • Brand colors. Replace the PALETTE array. The library reads fill and stroke attributes from inline SVG when rasterizing, so brand colors carry through.
  • Multi-quarter comparison. Add a second BarChart underneath the revenue trend with data={monthlyPnlComparison}. Each chart is independent.

On this page