Timelines: markers on a connector rail

June 10, 2026 · View on GitHub

addTimeline builds a vertical timeline: a sequence of entries, each a TimelineMarker sitting in a continuous connector rail, paired with its content (title, meta, body). Pairing the marker with its entry — instead of hand-placing a bullet plus a left margin per row — is the semantic win. The rail auto-stretches to each entry's height, so it spans variable-length content and reads as one continuous line.

A basic timeline

import com.demcha.compose.document.dsl.TimelineMarker;
import com.demcha.compose.document.style.DocumentColor;

DocumentColor accent = DocumentColor.rgb(40, 90, 120);

section.addTimeline(timeline -> timeline
        .entry(TimelineMarker.dot(8, accent), e -> e
                .title("Senior Engineer")
                .meta("2021 - present")
                .body("Led the rendering pipeline rewrite and mentored three engineers."))
        .entry(TimelineMarker.dot(8, accent), e -> e
                .title("Engineer")
                .meta("2019 - 2021")
                .body("Shipped the layout engine and the table system.")));

Each entry slot is optional — a marker with only a title, or only a body, renders fine. e.add(content -> ...) appends arbitrary extra blocks below the body (chips, nested rows, lists), configured against the entry's content section.

Marker kinds

import com.demcha.compose.document.style.DocumentStroke;

TimelineMarker.dot(8, accent);                          // solid filled dot
TimelineMarker.circle(10, null,                         // outlined ring
        DocumentStroke.of(accent, 1.2));
TimelineMarker.numbered(3, 16, accent,                  // numbered disc
        DocumentColor.WHITE);
TimelineMarker.square(8, accent);                       // filled square

circle(size, fill, stroke) takes an optional fill and/or outline — pass a fill for a two-tone disc, or only a stroke for an empty ring. numbered(n, size, fill, textColor) centres the step number in the disc; the label scales with the disc size. A marker carries its own size into the rail column, so mixed marker sizes in one timeline lay out correctly.

Rail and geometry

section.addTimeline(timeline -> timeline
        .connector(DocumentColor.rgb(150, 158, 172), 1.5) // rail colour + width
        .gutter(8)              // rail → marker/content gap
        .markerGap(8)           // marker → title gap
        .markerColumnWeight(0.12) // widen for large numbered discs
        .spacing(14)            // vertical gap between entries
        .entry(TimelineMarker.dot(8, accent), e -> e.title("Kick-off")));

The rail is a left accent border on each entry that spans the entry spacing too, so the line never breaks between entries. Increase markerColumnWeight (relative to a content weight of 1.0) when large numbered discs crowd a narrow timeline.

Text styles

Timeline-wide defaults and per-entry overrides:

import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;

section.addTimeline(timeline -> timeline
        .titleStyle(DocumentTextStyle.builder()
                .fontName(FontName.HELVETICA_BOLD)
                .size(11)
                .build())
        .metaStyle(DocumentTextStyle.builder()
                .fontName(FontName.HELVETICA)
                .size(8.5)
                .build())
        .entry(TimelineMarker.dot(8, accent), e -> e
                .title("Launch", DocumentTextStyle.builder()  // per-entry override
                        .fontName(FontName.HELVETICA_BOLD)
                        .size(13)
                        .build())
                .meta("June 2026")
                .body("General availability.")));

titleStyle / metaStyle / bodyStyle on the timeline set the defaults for every entry; the two-argument title(text, style), meta(text, style), and body(text, style) (or the matching *Style(...) setters on the entry) override one entry.

Pagination

A timeline paginates between entries by default, and a tall entry splits within itself — the rail continues across the page break. Two opt-in controls tighten that (both @since 1.8.0):

section.addTimeline(timeline -> timeline
        .keepTogether()          // relocate the whole timeline to a fresh page
        .keepEntriesTogether()   // never split one entry across pages
        .entry(TimelineMarker.dot(8, accent), e -> e.title("Atomic entry")));

keepTogether() moves the whole timeline to the next page when it does not fit in the remaining space but would fit on a fresh page — timelines taller than a page still flow. keepEntriesTogether() keeps each entry whole while still allowing breaks between entries. Same semantics as the section-level controls in the keep-together recipe.

Runnable demo: TimelineDemoTest renders a marker/rail sheet to target/visual-tests/timeline/timeline.pdf.