FrameSeq

Layout#

FrameSeq starts with useful single-column defaults. Add a layout only when a page needs a meaningful composition.

For the signature, parameters, behavior, and a compact example of every layout function, see the Function reference.

Standard page#

slide("One idea");
text("A standard page has a heading and a vertical content region.");
bullets("First point", "Second point");

Centered page#

slide({ name: "Quote" }).center();
text("Simplicity is a feature.").quote();

center() centers the normal content region horizontally and vertically.

Center one object#

center() centers a whole region. When the rest of the page should stay where it is, center the one object instead:

slide("Result");
text("The measurement that matters");
text("94.6% accuracy").width(520).centerSelf();
bullets("Held-out set", "Three seeds");

centerSelf() centers an object across the axis of the region that holds it — horizontally in a column, vertically in a row — and leaves its siblings untouched. selfAlign("start" | "center" | "end" | "stretch") picks any of the other alignments.

An object stretches across the axis by default, so it only moves once it has a size of its own. In a column that means .width(...); in a row, .height(...). Without a width, a full-width object is already as wide as the region, and textAlign("center") is what centers the words inside it:

text("Centered words in a full-width object").textAlign("center");

To center an object on both axes without centering the whole region, give it a region of its own:

at("hero").center().grow();
text("94.6% accuracy").width(520);

grow() lets that region take the height left over, so center() has room to work in.

Which axis a modifier moves#

Every region is a row or a column, and each modifier works on one of its two axes. Choosing the wrong one is the most common layout surprise, so read this table by asking "am I moving the objects along the region, or across it?"

Goal In a column In a row
Move every child across the region align() align()
Move one child across the region selfAlign() / centerSelf() selfAlign() / centerSelf()
Space the children along the region justify() justify()
Push one child to the far end spacer() before it spacer() before it
Move the text inside an object textAlign() textAlign()

align() and selfAlign() work across the region: horizontally in a column, vertically in a row. justify() works along it: vertically in a column, horizontally in a row. So bottom-aligning one card in a column is justify() on the column or a spacer(), never selfAlign("end").

A modifier used where it cannot mean anything — align() on a text(), grow() inside a stack() — is silently ignored by the browser. frameseq check reports those as inert-modifier; see AI-friendly layout checks.

Push objects apart#

at("footer").row().gap(0);
text("FrameSeq").caption();
spacer();
text("2026").caption();

spacer() is empty space that takes whatever room is left over, which pushes what follows it to the far end of the region. Two spacers of the same size split the free space evenly, and spacer(2) takes twice the share of spacer(1).

Split page#

slide("Architecture").split("40:60");

image(diagram, "Compiler diagram");

right();
text("Compiler").lead();
bullets("TypeScript DSL", "HTML renderer", "PDF export");

Content written before split() moves into the left region. Subsequent content also starts on the left. Use right() and left() to switch destinations.

Accepted ratios include:

slide("A").split();            // 1:1
slide("B").split("40:60");    // ratio string
slide("C").split([2, 3]);      // number pair
slide("D").split(0.4);         // left fraction
slide("E").split(40);          // left percentage

Both sides of the ratio must be positive.

Grid page#

slide("Results").grid(3, 20);

cell(0);
metric("42%", "Growth");

cell(1);
metric("18K", "Users");

cell(2);
metric("99.9%", "Uptime");

Grid indices start at zero. A grid accepts 1–12 columns. Content written before grid() moves into cell 0.

grid() states the columns, not how many cells the page needs, so a cell beyond the first row is created when it is first addressed and wraps onto the next row:

slide("Six results").grid(3, 20);

cell(0);
metric("42%", "Growth");
// …
cell(5);
metric("4.8", "Rating");   // second row, third column

Local grid section#

Use gridSection() when only part of a slide should use a grid. Each supplied object becomes one cell, and the section stays between the normal content written before and after it:

slide("Results");
text("Performance this quarter");

gridSection(
  3,
  metric("42%", "Growth"),
  metric("18K", "Users"),
  metric("99.9%", "Uptime"),
).gap(20);

text("All targets were exceeded.");

Cells are filled in source order and wrap into additional rows when there are more items than columns. A string template supports unequal columns:

gridSection(
  "1fr 2fr",
  card("Context", "A compact supporting point"),
  card("Main result", "Give the primary result more room"),
);

For a cell made from multiple objects, use group():

gridSection(
  2,
  group(text("Revenue").bold(), text("$1.2M").size(42)).card(),
  group(text("Users").bold(), text("18K").size(42)).card(),
);

gridSection() expresses a local parent-child relationship without requiring manual cell selection. Prefer it over a canvas for card rows, metrics, feature comparisons, and other regular two-dimensional arrangements.

Name a container before its contents#

When the container reads better than the objects inside it, name it with at() and keep writing content normally. Nothing needs a local variable:

at("diagram").canvas().width(640).height(280).clip();

rect("Input").position({ x: 40, y: 80 });
circle("Model").position({ x: 360, y: 60 });

Container .canvas() establishes a local coordinate system, so .position() values are relative to that container instead of the whole slide. .clip(false) allows positioned children to extend beyond its bounds.

When the objects come first and the container second, name the objects and collect them afterwards:

card("Quality", "Higher is better").as("quality");
metric("94.8%", "Accuracy").card().as("accuracy");

gridSection(2, "quality", "accuracy").gap(20);

Each object is created by the normal linear command and moved once into the grid; it is never rendered twice. Objects must belong to the current region of the same slide.

Region paths#

at(path) moves the authoring cursor to a region addressed by a path and creates the containers it names. It is the flat alternative to nesting: every object stays one statement, and there is no closing call, because the next at() ends the previous region.

slide("Roadmap").grid(2);

at("cell0/now").card();
text("Q3").eyebrow();
bullets("Anchors", "Region paths");

at("cell1/next").card();
text("Q4").eyebrow();

at("cell0/now");
text("Merged into main").caption();

The first segment may address a region the slide layout already owns — main, left, right, cell0, cell1, and so on — so at("cell1") and cell(1) select the same region. Every other segment is created the first time it is used, at any depth:

at("notes").row().gap(24);
at("notes/left");
text("Left copy");
at("notes/right");
text("Right copy");

A region can also be a grid, which is the shorter way to write a matrix:

at("cards").grid(3).gap(20);
card("First", "…");
card("Second", "…");
card("Third", "…");

Set a region's layout where it first appears, then use the bare path afterwards. Revisiting a path returns the same region and appends to it, so a page can be written in the order that reads best rather than in the order the containers nest. Paths are scoped to their slide: the same path on the next slide is a new region.

For diagrams, Diagrams shows how region paths, rows, and connectors combine into a page with no coordinates. FrameSeq registers each path as an anchor name, so a positioned region can be connected like any other object:

slide("Stages").canvas();

at("stages").canvas().position({ x: 200, y: 60 }).width(400).height(300);
rect("Parse").as("parse").position({ x: 20, y: 40 });

at("");
rect("Output").as("output").position({ x: 800, y: 100 });
line().from("output").to("stages");

Return to the primary region#

main();

main() selects the normal body, the left side of a split, or the first grid cell. Starting another slide resets the region automatically.

Region spacing#

gap(24);
gap(12, 40);   // rows, then columns

gap() changes the spacing between children in the current region. Numbers are pixels; unit helpers are also accepted. A second value spaces the columns separately from the rows, which a grid or a wrapped row needs.

Full-bleed image#

slide({ name: "Landscape" }).fullBleed(photo, "Mountain landscape");

Use an object-form slide without title when the image should occupy the page without a standard heading.

Freeform canvas#

Change the current slide body into a positioned canvas, then set exact coordinates on any element:

slide({ name: "System map" }).canvas();

text("Compiler")
  .position({ x: 80, y: 90 })
  .width(320)
  .size(32)
  .bold();

image("diagram.png", "Compiler diagram")
  .position({ x: 500, y: 80 })
  .width(620);

The x and y coordinates are relative to the current canvas region. Plain numbers are pixels; unit helpers are also accepted. FrameSeq maps the fixed presentation canvas as one unit, so positioned elements retain their relative placement in interactive HTML, PDF, and PPTX.

The explicit object API is also available when an element should be created before it is attached:

import { Slides, Text, Image, px } from "@pride7/frameseq";

const slides = Slides("Diagram");
const page = slides.slide({ name: "Canvas" }).canvas();

page.custom(
  Text("Compiler").size(32).bold().position({ x: px(80), y: px(90) }).width(px(300)),
  Image(diagram, "Compiler diagram").position({ x: px(520), y: px(120) }).width(px(620)),
);

export default slides;

Prefer structured layouts for most pages. Use a canvas for diagrams and custom compositions that genuinely need exact placement.

For diagram primitives designed for this canvas, see Shapes and connectors.