Documentation

Positioning

Most layout flows top to bottom. Sometimes you want one thing to sit ON TOP of the flow: a badge on a card, a ribbon, a title that cuts into a box, a watermark. relative frames and Positioned children do exactly that. It is the CSS positioning model.

A badge on a card

Mark a Box as a frame with relative: true. Inside it, wrap anything in Positioned and give it offsets from the frame's edges. A Positioned child is OUT OF FLOW: it reserves no space and pushes nothing, it just draws where you put it. A negative offset pokes it outside the frame, the "cutting in" look.

import { writeFileSync } from "node:fs";
import { Document, Page, Box, Text, Positioned, renderToBytes } from "@jasy/pdf";

const tag = (bg: string, fg: string, t: string) =>
  Box({ bg, radius: 6, padding: { x: 11, y: 6 } }, [Text(t, { size: 11, bold: true, color: fg })]);

async function build() {
  const doc = Document([
    Page({ size: "A4", margin: 56 }, [
      Box({ relative: true, bg: "#eef3fb", border: "#1450aa", radius: 12, padding: 24 }, [
        Text("Project Aurora", { size: 18, bold: true, color: "#0a2348" }),
        Text("Badges anchor to the card's corners.", { size: 12, color: "gray" }),
        Positioned({ top: -12, left: -12 }, tag("#f3dc29", "#0a2348", "NEW")), // pokes out
        Positioned({ top: 14, right: 14 }, tag("#1450aa", "#ffffff", "v2")), // inside the corner
      ]),
    ]),
  ]);

  writeFileSync("badge.pdf", await renderToBytes(doc));
}

build();

Anchoring

There are two ways to place a child - use whichever reads best, even one per axis.

Pin to an edge. top / left / right / bottom are distances from the frame's edges. Set one per axis; the child shrink-wraps to its content, and a negative value pokes it out.

Positioned({ top: 14, right: 14 }, badge); // inside the top-right corner
Positioned({ bottom: -10, left: -10 }, badge); // pokes out the bottom-left

Anchor it. h and v snap the child to a point - start, center or end on each axis - and x / y nudge it from there. So "dead center, ten points up" is { h: "center", v: "center", y: -10 }. This is the thing edges cannot do cleanly: center.

Positioned({ h: "center", v: "center" }, watermark); // dead center of the frame
Positioned({ h: "end", v: "end", x: -8, y: -8 }, stamp); // bottom-right, inset 8
Positioned({ h: "center", v: "start", y: 12 }, ribbon); // top-center, nudged 12 down

An edge wins over an anchor on the same axis. With no relative ancestor, a Positioned anchors to the page's content box.

Cropping with overflow

A frame's overflow decides what happens to a child that runs past its edge: "visible" (the default) lets it spill, "hidden" crops it at the frame, rounded corners and all. Here is the same card twice, side by side, with only overflow changed.

import { writeFileSync } from "node:fs";
import { Document, Page, Row, Box, Text, Positioned, Expanded, renderToBytes } from "@jasy/pdf";

// a blue box pinned inside the card, extending past its bottom edge
const card = (overflow: "visible" | "hidden") =>
  Box(
    {
      relative: true,
      overflow,
      bg: "#eef3fb",
      border: "#1450aa",
      radius: 10,
      height: 70,
      padding: 14,
    },
    [
      Text(`overflow: "${overflow}"`, { size: 12, bold: true, color: "#0a2348" }),
      Positioned(
        { top: 34, left: 14 },
        Box({ bg: "#1450aa", radius: 8, width: 170, height: 100, padding: 12 }, [
          Text("Pinned inside the card, it extends past the bottom edge.", {
            size: 10,
            color: "#ffffff",
          }),
        ]),
      ),
    ],
  );

async function build() {
  const doc = Document([
    Page({ size: "A4", margin: 56 }, [
      Row({ gap: 24 }, [Expanded(card("visible")), Expanded(card("hidden"))]),
    ]),
  ]);

  writeFileSync("overflow.pdf", await renderToBytes(doc));
}

build();

Left, the blue box hangs past the card. Right, the identical box is cropped flush at the card's edge. overflow: "hidden" also crops an Image to a rounded box, a clean way to get a rounded or circular photo.

A reusable component

A component is just a function returning a tree, so "a card with a cut-in title" is a few lines you reuse everywhere.

import { Box, Padding, Positioned, Text, type PDFElement } from "@jasy/pdf";

const TitledCard = (title: string, body: PDFElement) =>
  Box({ relative: true, overflow: "hidden", bg: "#eef3fb", radius: 10 }, [
    Padding(20, body),
    Positioned(
      { top: -10, left: -10 },
      Box({ bg: "#1450aa", radius: 6, padding: { x: 12, y: 6 } }, [
        Text(title, { color: "white", bold: true }),
      ]),
    ),
  ]);
jasypdf

Declarative PDFs in pure TypeScript. ZUGFeRD & XRechnung compliant, with no headless browser and no Java.

Resources

© 2026 Florian Heuberger · MIT License

Built with Nuxt · self-hosted fonts · no trackers