Documentation

Layout

Every layout is built from a handful of primitives. Column and Row stack their children, Spacer and Expanded distribute the space between them, and Box and Padding frame and inset content.

Column and Row

Column stacks its children top to bottom, Row left to right. Both take the same options.

Column({ gap: 12 }, [Text("First"), Text("Second")]);

Row({ gap: 8, justify: "between", align: "center" }, [
  Text("Total", { size: 16, bold: true }),
  Text("100.00 EUR"),
]);
PropTypeWhat it does
gapnumberspace inserted between children, in points
justify"start" | "center" | "end" | "between" | "around"distribution along the stacking axis (like CSS justify-content)
align"start" | "center" | "end" | "stretch"position across the axis (like CSS align-items)
wrapbooleanlet children flow onto further lines when they do not fit
alignContent"start" | "center" | "end" | "between" | "around" | "stretch"how the WRAPPED lines sit as a block
reversebooleanlay the children out in the opposite order
width heightnumber | stringfix a dimension, in points or as "50%" of the offered space

justify: "between" pushes the first and last child to the edges. align: "center" lines children up on the cross axis. Both default to start.

Wrapping, and per-child control

With wrap, children that do not fit start a new line, and alignContent places those lines as a block. Individual children get three props of their own, on any element:

Row({ wrap: true, gap: 8, alignContent: "between" }, [
  Box({ width: "33%" }, [Text("one")]),
  Box({ width: "33%" }, [Text("two")]),
  Box({ width: "33%", order: -1 }, [Text("drawn first")]),
  Box({ flexShrink: 0 }, [Text("keeps its width, and overflows")]),
  Expanded({ flex: 1, flexBasis: 120 }, Box([Text("starts at 120pt, then grows")])),
  Box({ alignSelf: "end" }, [Text("overrides the row's align")]),
]);

flexShrink is 1, as in CSS: when a line overflows, its children give space back in proportion to their own width until the line fits. 0 opts a child out, so it keeps its width and runs past the edge.

Nothing is squeezed past what it can actually hold. A child's floor is its min-content extent - for a text, its longest word - which is the same rule a browser applies through the automatic min-width of a flex item. Once a child sits on its floor it stops giving, and the share it could not absorb goes to the ones that still can.

A percentage width inside a flex line resolves against the line minus its gaps, so N columns at (100/N)% fit exactly - CSS makes you subtract the gaps yourself.

A layout's align uses the cross-axis vocabulary start / center / end (it is direction-agnostic - for a Row the cross axis is vertical, so left/right would not make sense). That differs from a Text's own align, which is left / center / right.

Spacer

Spacer is flexible empty space. Drop one between two items and it pushes them apart.

Row([Text("Subtotal"), Spacer(), Text("100.00 EUR")]);

Pass a number to weight several spacers against each other: Spacer(2) takes twice the space of Spacer(1).

Expanded

Expanded makes a child grow to fill the remaining space. Give each one a flex weight to share the space in proportion.

Row({ gap: 12 }, [
  Expanded({ flex: 1 }, Box({ border: "steelblue", padding: 12, radius: 6 }, [Text("flex 1")])),
  Expanded(
    { flex: 2 },
    Box({ bg: "#1450aa11", padding: 12, radius: 6 }, [Text("flex 2, twice as wide")]),
  ),
]);
PropTypeWhat it does
flexnumberthe child's share of the remaining space (default 1)

Expanded takes a single child, not an array. In a Row it fills width, in a Column it fills height.

Box

Box is the styled container: a background, a border, rounded corners, padding and an optional fixed size. By default it fills the width it is offered (like a block element) and shrink-wraps its height to its content. Set width or height to fix a dimension. (As a fixed child in a Row it shrink-wraps its width too, since there it is not offered a width to fill.)

Box({ bg: "#1450aa11", border: "steelblue", borderWidth: 1, radius: 8, padding: 16 }, [
  Text("A framed, padded, rounded box."),
]);
PropTypeWhat it does
bgColorInputfill color
borderColorInputborder color (all sides)
borderTop borderRight borderBottom borderLeftColorInputcolor a single side
borderWidthnumberborder thickness (default 1 when a border is set)
radiusnumber | Cornerscorner radius - one number, or per corner
paddingInsetsinner spacing (see below)
width heightnumber | stringa fixed size in points, or "50%" of the offered space
minWidth maxWidth minHeight maxHeightnumber | stringclamp a dimension
aspectRationumberderive the axis you did not pin (16 / 9)
overflow"hidden"crop the children to the box, rounded corners and all
relativebooleanmake this box the frame for Positioned children

Sizing, in the order it happens

The order is the contract, and it follows CSS: relative sizing → the aspect ratio fills whichever axis was left open → min/max clamp. So an explicit bound beats the ratio, the way min-height beats aspect-ratio in a browser.

Box({ width: "50%", maxWidth: 100 }, []); // 50% of a 400pt region is 200, clamped to 100
Box({ width: 240, aspectRatio: 16 / 9 }, []); // height derived: 135

Percentages on padding and margin resolve against the width on all four sides, top and bottom included. That is the CSS quirk, kept on purpose so a layout ported from the web behaves the same.

Gradients

bg takes a gradient as readily as a colour. It is written box-relative - an angle and stops - and resolved against the box when it is drawn:

import { linearGradient, radialGradient } from "@jasy/pdf";

Box({ bg: linearGradient({ angle: 135, stops: ["#1450aa", "#7aa5e8"] }), padding: 20 }, [...]);
Box({ bg: radialGradient({ stops: ["#ffffff", "#e2483d"] }), width: 80, height: 80 }, []);

Angles follow CSS: 0 points up, and it turns clockwise.

Corners, one at a time

radius also takes a corner object. Two radii that share an edge and would overlap are scaled down together, so the shape stays smooth - react-pdf clamps each corner on its own, which pinches.

Box({ radius: { topLeft: 16, bottomRight: 16 }, bg: "#eef1f5", padding: 16 }, [...]);

Padding

Padding insets a single child without drawing anything. It is the spacing-only cousin of Box.

Padding({ top: 16, bottom: 8 }, Text("Breathing room above and below."));

Anywhere a spacing value is taken (padding on a Box, the Padding element), you can write it four ways:

16                       // all four sides
{ x: 16, y: 8 }          // horizontal / vertical
{ top: 16, left: 8 }     // per side, any subset
[16, 8, 16, 8]           // [top, right, bottom, left]

Divider

Divider draws a thin horizontal rule across the full width of its parent - a clean separator between sections, totals or list items.

Column({ gap: 4 }, [Text("Subtotal"), Divider(), Text("Total", { bold: true })]);
PropTypeWhat it does
colorColorInputline color (default a light gray)
thicknessnumberline thickness in points (default 1)
marginInsetsspace above / below the rule (default a small gap)
Divider({ color: "#1450aa", thickness: 2, margin: { y: 12 } });

Colors

Wherever a color is taken (color, bg, border), jasy accepts several forms. rgb and rgba are small helpers you import from the package.

import { rgb, rgba } from "@jasy/pdf";

// every entry below is a valid color value:
const colors = [
  "steelblue", // any CSS color name
  "#1450aa", // hex
  "#1450aacc", // hex with alpha
  0xff1450aa, // ARGB number, alpha first
  rgb(20, 80, 170), // channels 0 to 255
  rgba(20, 80, 170, 0.5), // ... with an alpha of 0 to 1
  "rgb(20, 80, 170)", // the CSS function, as a string
  "rgb(20 80 170 / 50%)", // the modern space form, with slash-alpha
  "hsl(214, 79%, 37%)", // and hsl(), the same sRGB family
];

A wide-gamut function such as color(display-p3 …) or light-dark() is named and refused rather than squashed into sRGB - the result would be a colour nobody chose.

See it all in one file

Here is every primitive on this page in a single document. A Document can hold more than one Page, so each page below demonstrates one idea. Copy it, run it once, and flip through the result.

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

const heading = (t: string) => Text(t, { size: 22, bold: true, color: "#1450aa" });
const sub = (t: string) => Text(t, { size: 12, color: "gray" });

async function build() {
  const doc = Document([
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 16 }, [
        heading("Column and Row"),
        sub("Column stacks top to bottom, Row stacks left to right."),
        Divider({ color: "steelblue" }),
        Row({ justify: "between", align: "center" }, [
          Text("Left", { bold: true }),
          Text("Right", { bold: true }),
        ]),
      ]),
    ]),
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 16 }, [
        heading("Spacer"),
        sub("Flexible empty space that pushes items apart."),
        Divider({ color: "steelblue" }),
        Row([Text("Subtotal"), Spacer(), Text("100.00 EUR")]),
      ]),
    ]),
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 16 }, [
        heading("Expanded"),
        sub("Children grow to share the remaining space by their flex weight."),
        Divider({ color: "steelblue" }),
        Row({ gap: 12 }, [
          Expanded(
            { flex: 1 },
            Box({ border: "steelblue", padding: 14, radius: 6 }, [Text("flex 1")]),
          ),
          Expanded({ flex: 2 }, Box({ bg: "#1450aa11", padding: 14, radius: 6 }, [Text("flex 2")])),
        ]),
      ]),
    ]),
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 16 }, [
        heading("Box"),
        sub("A background, a border, rounded corners and padding."),
        Divider({ color: "steelblue" }),
        Box({ bg: "#1450aa11", border: "steelblue", borderWidth: 1, radius: 8, padding: 18 }, [
          Text("A framed, padded, rounded box."),
        ]),
      ]),
    ]),
    Page({ size: "A4", margin: 56 }, [
      Column({ gap: 16 }, [
        heading("Padding"),
        sub("Insets a single child without drawing anything."),
        Divider({ color: "steelblue" }),
        Box({ border: "steelblue", radius: 6 }, [
          Padding(
            { x: 20, y: 14 },
            Text("This text sits inside 20pt horizontal, 14pt vertical padding."),
          ),
        ]),
      ]),
    ]),
  ]);

  writeFileSync("layout-tour.pdf", await renderToBytes(doc));
}

build();

Run it, open layout-tour.pdf, and page through Column and Row, Spacer, Expanded, Box and Padding.

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