| name | typst-cards |
| description | Generate PNG images for online communication — social media, carousels, infographics, posts — using Typst. Use this skill whenever the user wants to create slides, cards, visual posts or any digital graphic content, even if they don't explicitly mention Typst. The skill drives an interview about brand materials (logo, palette, fonts, DESIGN.md), proposes the formats best suited to the context (Instagram 1:1, Stories 9:16, LinkedIn 16:9, etc.) and produces ready-to-use PNGs. |
Typst Cards
Purpose
Turn a textual context and optional brand materials into professional PNG images for online communication, using Typst as the rendering engine. The skill manages the full flow: interview → theme → generation → review.
Prerequisites — verify Typst
Before any other step, check that Typst is installed:
typst --version
If not available, install it like this (Linux/WSL x86_64):
curl -fsSL https://github.com/typst/typst/releases/latest/download/typst-x86_64-unknown-linux-musl.tar.xz -o /tmp/typst.tar.xz && tar -xf /tmp/typst.tar.xz -C /tmp/ && mkdir -p ~/.local/bin && mv /tmp/typst-x86_64-unknown-linux-musl/typst ~/.local/bin/
Note: ~/.local/bin must be in your PATH for typst to be found. Add export PATH="$HOME/.local/bin:$PATH" to your shell profile if needed.
Phase 1 — Interview (single message, mandatory)
Before creating any file, ask these questions to the user in a single message:
Visual materials available?
- Do you have a logo? If yes, where is the file? (PNG, SVG, or other)
- Do you have a
DESIGN.md or brand guidelines to share?
- Do you have color preferences? (specific palette, or a mood description: "dark and technical", "colorful and lively", "institutional", etc.)
- Do you have specific fonts to use?
Desired output:
- What is the topic or content of the cards?
- How many slides or cards? (a single image or a carousel?)
- Which formats do you need? (propose those best suited to the context, see table below)
If the user has no brand materials, propose a palette consistent with the context and ask for a quick confirmation before proceeding.
Supported formats
| Name | Typical use | Pixels | width | height | PPI |
|---|
| Square 1:1 | Instagram post, carousels | 1080×1080 | 7.5in | 7.5in | 144 |
| Portrait 4:5 | Instagram portrait | 1080×1350 | 7.5in | 9.375in | 144 |
| Story 9:16 | Instagram/TikTok Stories, Reels | 1080×1920 | 7.5in | 13.33in | 144 |
| Landscape 16:9 | Twitter/X, YouTube thumb, LinkedIn header | 1920×1080 | 13.33in | 7.5in | 144 |
| LinkedIn/OG 1.91:1 | LinkedIn post, Open Graph meta | 1200×628 | 8.33in | 4.36in | 144 |
Math: pixels = inches × PPI → e.g. 7.5in × 144ppi = 1080px.
For carousels, always use the same format for every slide.
Phase 2 — Material analysis
With DESIGN.md: read it and extract — and apply in theme.typ:
- Colors: every token (primary, secondary, accent, surface, neutral…)
- Fonts: if the DESIGN.md specifies different fonts for different roles (e.g. display vs body), use them all in theme.typ as separate variables (
DISPLAY, SANS, MONO). For example: a serif display face for headlines, a sans-serif for body.
- Visual style: restrained? bold? editorial? Steer the layout accordingly.
- Explicit rules: if the DESIGN.md says "do not use X on Y", honor it.
Do not collapse everything into a single font — the display/body distinction is part of the brand.
With logo: note the path. You'll include it in Typst with:
#image("path/to/logo.png", height: 0.5in) // fixed height
#image("path/to/logo.svg", width: 2in) // fixed width
Without materials: pick a palette based on context:
- Tech/data topic → dark background
#0d1117, accent blue #58a6ff
- Consumer/lifestyle topic → white background, vivid colors
- Institutional/PA topic → sober palette, neutral tones with a measured accent
- Editorial/journalism topic → strong typography, high contrast
Phase 3 — File structure
Always create this structure in the project directory:
<project>/carousel/
├── theme.typ # design tokens and helper functions
├── slides.typ # content (imports theme.typ)
└── output/ # generated PNGs
├── slide-1.png
├── slide-2.png
└── ...
Recommended starting point
Instead of writing from scratch, copy one of the reference templates from the skill folder and adapt it:
references/theme-starter.typ → palette, fonts, helpers (lbl, footer-block, codebox, divider)
references/slides-1x1-starter.typ → 5 Instagram 1:1 slides (cover + 3 content + outro)
references/slides-16x9-starter.typ → 3 LinkedIn 16:9 slides (cover + two-column + 3 stat)
The templates are already tested and compile cleanly. They give you a proven structure; you change colors, fonts, content.
cp <SKILL_DIR>/references/theme-starter.typ <project>/carousel/theme.typ
cp <SKILL_DIR>/references/slides-1x1-starter.typ <project>/carousel/slides.typ
theme.typ — design tokens
Adapt colors to the materials gathered. This is a starting point:
// — palette —
#let BG-DARK = rgb("#0d1117")
#let BG-LIGHT = rgb("#f6f8fa") // light background
#let BG-WHITE = rgb("#ffffff") // pure white
#let ACC = rgb("#58a6ff") // accent on dark background
#let ACC-L = rgb("#0969da") // accent on light background
#let FG-DARK = rgb("#e6edf3") // text on dark
#let FG-LIGHT = rgb("#1c2128") // text on light
#let MUTED-D = rgb("#8b949e") // secondary on dark
#let MUTED-L = rgb("#656d76") // secondary on light
#let CODE-BG = rgb("#161b22")
#let CODE-BR = rgb("#30363d")
// — fonts —
// Safe fonts on Linux/WSL: DejaVu Sans, DejaVu Sans Mono
// Check availability: fc-list | grep -i "Font Name"
#let SANS = ("DejaVu Sans", "Liberation Sans", "Arial")
#let MONO = ("DejaVu Sans Mono", "Liberation Mono", "Courier New")
// — helper: section label —
#let lbl(body, dark: false) = text(
size: 9pt, weight: "bold", tracking: 2pt,
fill: if dark { ACC } else { ACC-L },
)[#upper(body)]
// — helper: page footer (URL + native page counter) —
// Apply via #set page(footer: footer-block("github.com/me/repo"))
#let footer-block(url, dark: false) = context [
#line(length: 100%, stroke: 0.5pt + if dark { CODE-BR } else { rgb("#d0d7de") })
#v(0.06in)
#grid(columns: (1fr, auto),
text(font: MONO, size: 9pt, fill: if dark { MUTED-D } else { MUTED-L })[#url],
text(font: MONO, size: 9pt, fill: if dark { MUTED-D } else { MUTED-L })[
\# #counter(page).display() / #counter(page).final().first()
],
)
]
// — helper: code block —
#let codebox(body) = block(
fill: CODE-BG, stroke: 0.5pt + CODE-BR,
radius: 4pt, inset: (x: 14pt, y: 11pt), width: 100%,
)[
#set text(font: MONO, size: 10.5pt, fill: FG-DARK)
#body
]
slides.typ — base structure
#import "theme.typ": *
// Pick the format (only one active line). Keep `bottom` margin generous
// (~0.7in) so the footer line + 9pt text fit fully without clipping.
#set page(width: 7.5in, height: 7.5in, margin: (x: 0.55in, top: 0.5in, bottom: 0.7in), footer-descent: 18pt) // 1:1
// #set page(width: 7.5in, height: 9.375in, margin: (x: 0.55in, top: 0.5in, bottom: 0.7in), footer-descent: 18pt) // 4:5
// #set page(width: 7.5in, height: 13.33in, margin: (x: 0.55in, top: 0.6in, bottom: 0.75in), footer-descent: 20pt) // 9:16
// #set page(width: 13.33in, height: 7.5in, margin: (x: 0.85in, top: 0.55in, bottom: 0.72in), footer-descent: 20pt) // 16:9
// #set page(width: 8.33in, height: 4.36in, margin: (x: 0.55in, top: 0.4in, bottom: 0.6in), footer-descent: 16pt) // 1.91:1
#set text(font: SANS, size: 15pt, fill: FG-LIGHT)
// Footer URL — edit once for the deck. Counter is auto-numbered.
#let URL = "github.com/<org>/<repo>"
// — SLIDE 1 (dark) — set fill + matching dark footer together
#set page(fill: BG-DARK, footer: footer-block(URL, dark: true))
#v(1fr)
#lbl(dark: true)[tag · topic]
#v(0.15in)
#text(size: 44pt, weight: 900, fill: FG-DARK)[
Main title\
of the slide
]
#v(0.2in)
#text(size: 14pt, fill: MUTED-D)[Short subtitle or description]
#v(1fr)
// — SLIDE 2 (light) — switch fill → new page automatically
#set page(fill: BG-LIGHT, footer: footer-block(URL, dark: false))
#lbl[02 · section]
#v(0.2in)
#text(size: 31pt, weight: 900)[Section title]
#v(0.2in)
#text(size: 14pt, fill: MUTED-L)[Slide body text.]
// — SLIDE 3 (same background as slide 2 → explicit pagebreak) —
#pagebreak()
// ... content ...
Why the footer goes through #set page(footer: ...) and not a manual #ctr at the end of each slide: the page-level footer uses Typst's native page counter, so adding/removing slides requires no renumbering. It also lives in the bottom margin, so it cannot be pushed to the next page by dense content (see "fragile footer pattern" in pitfalls).
Compilation
cd <project>/carousel
typst compile slides.typ "output/slide-{p}.png" --ppi 144
The {p} is replaced by the page number → one PNG per slide.
Design principles for social cards
Text hierarchy: large title → subtitle → body. Max 3 levels. Don't crowd.
White space: generous margins (0.5–0.7in). Let the content breathe.
Colors: 2–3 max. A vivid accent on a neutral background always works.
Font sizes on a 1080px canvas (1pt Typst ≈ 2px output):
- Cover title: 42–50pt
- Section title: 28–34pt
- Body: 14–16pt
- Label/tag: 9–10pt bold uppercase with tracking
Carousels: counter at bottom right of every slide (1 / 6, 2 / 6, ...).
Story 9:16: center the content vertically, use larger fonts, avoid corners.
Footer/edge content never clips: any text near the page borders — footer, slide counter (ctr), source URL, page number — must be fully visible, no half-cut letters. If clipping occurs, increase the page bottom margin (#set page(margin: (x: ..., y: ...))) or reduce the footer font size. This is a must-check in Phase 4.
Known pitfalls
Grid with wide numbers/text: columns: (1fr, 1fr, 1fr) causes overflow if values are wide. Use a vertical layout or columns: (auto, 1fr) with a generous gutter.
Unavailable fonts: always specify fallbacks (("Chosen Font", "DejaVu Sans", "Arial")). Check with fc-list | grep -i "name". A warning is not an error: Typst uses the fallback.
#set page(fill: X) does not create a new page if X doesn't change: between slides with the same fill, use an explicit #pagebreak().
Fragile footer pattern — #v(1fr) + #ctr(n, total) as the last row of each slide: when content saturates the page, the 1fr space collapses to 0 and the counter slides onto the next page — you get a "ghost slide" with only the counter visible, no error. Prefer Typst's native page-level footer:
#set page(footer: footer-block(URL, dark: true))
// content of the slide — no v(1fr)+ctr at the end
The counter lives in the bottom margin and cannot be pushed by content. Overflow now manifests as a real extra page (immediately diagnosable) instead of a silent ghost slide.
v(1fr) works at page level: it splits the leftover space. If you place two of them, the space is split evenly between the two points.
Logo with transparent background: prefer SVG when possible. PNG with alpha works but requires that the slide background does not contrast badly with it.
Special characters in content mode: several characters open syntactic constructs in Typst content mode and cause "unclosed delimiter" errors when used literally. Escape with a backslash or rephrase:
| Char | What it opens in content | How to escape |
|---|
$ | math mode | \$ |
// | line comment | \/\/ or split with a space / / |
_ (adjacent to a word) | emphasis (italic) | \_ |
* (before a word) | strong (bold) | \* |
@ | reference | \@ |
< | label | use words ("less than") or $lt$ in math mode |
~ | non-breaking space (silent, rarely a problem) | \~ if you need it as literal output |
Typical contexts where you trip on this: terminal-style strings ($ command, ~$, exit_), paths like MIT // license, identifiers with _. Typst usually reports the error far from the actual cause, so when you see "unclosed delimiter" scan for these characters first.
Sub-case — strings passed as helper parameters are literal: when you call something like #item("$ command", "...") or #text(...)[#param] with a string variable, the string is treated literally — backslash escapes are NOT resolved. So "\$ command" outputs \$ command (with the visible backslash), while "$ command" outputs $ command correctly. The escape rule applies only inside content/markup, not inside string-typed parameter values.
leading is not a parameter of text(): it belongs to par(). To control line height of a text block:
// WRONG — error "unexpected argument: leading"
#text(size: 14pt, leading: 1.4em)[...]
// CORRECT — use par leading inside a block
#block[
#set par(leading: 0.7em)
#text(size: 14pt)[...]
]
align(center + horizon) with long text causes incorrect wordwrap: words can fuse. Prefer to handle vertical and horizontal alignment separately, or use block(width: 100%) to contain the text.
Editorial pattern (kicker + huge title + footer):
// magazine/newspaper style — battle-tested on 1:1
#text(size: 9pt, weight: "bold", tracking: 3pt, fill: ACC)[#upper("category · section")]
#v(0.08in)
#line(length: 100%, stroke: 1pt + ACC)
#v(0.25in)
#text(size: 14pt, fill: DIM)[Eyebrow]
#v(0.05in)
#text(size: 60pt, weight: 900)[Big title]
#v(0.05in)
#text(size: 18pt, weight: "bold", fill: ACC)[Accent subtitle]
#v(1fr)
#line(length: 100%, stroke: 0.5pt + rgb("#333333"))
#v(0.1in)
#grid(columns: (1fr, 1fr, 1fr),
text(size: 11pt)[Date],
align(center, text(size: 11pt)[Place]),
align(right, text(size: 11pt, fill: ACC)[CTA]),
)
Phase 4 — Review and iteration
After every compile:
- Read the PNGs with the Read tool — look at each slide, don't just confirm the file exists
- Evaluate against the design principles above: hierarchy, white space, contrast, text overflow/clipping, counter visibility, image quality
- Report and propose: list what works, what doesn't, and propose concrete fixes (e.g. "slide 3 title clips on the right — reduce from 44pt to 38pt", "increase top margin from 0.58in to 0.75in")
- Wait for user feedback before applying any change — do not auto-iterate
Typst recompiles in <1s, so iteration is cheap; each round must still be driven by an explicit user decision.