Card Composition
Reference for root-level card fields validated in lib/validation/chart-spec.ts. See also Chart Specifications for chart types, datasets, and Chart.js options.
1. What is card composition?
Chart-Output renders a complete designed card — not just a chart. The card system layers a header, optional legend row, the Chart.js plot, a KPI strip, and a footer into a single PNG (or other raster format). Every element is configurable via JSON on the same root object as type, data, and options.
2. Card anatomy
┌─────────────────────────────────────────────┐
│ HEADER (eyebrow · title · subtitle · badge) │
├─────────────────────────────────────────────┤
│ LEGEND (if position: top) │
├─────────────────────────────────────────────┤
│ │
│ CHART AREA (Chart.js) │
│ (fills remaining height) │
│ │
├─────────────────────────────────────────────┤
│ KPI STRIP value · value · value · value │
├─────────────────────────────────────────────┤
│ FOOTER left text right text │
└─────────────────────────────────────────────┘3. Full field reference
Types below match ChartSpecSchema in the repo. theme uses .passthrough() so extra keys may be honored by the theme pipeline when supported.
backgroundColor
Type: string — Outer card fill when card composition is active (e.g. "#0d1117").
borderRadius
Type: number (0–64) — Corner radius of the card shell in px.
border
Type: { color: string; width: number } — width is 0–8 px. Example: { "color": "rgba(255,255,255,0.08)", "width": 1 }.
padding
Type: "email" | "slack" | "pdf" — Merges preset padding into options.layout.padding. For numeric or per-side padding, set options.layout.padding in Chart.js form instead.
scale / devicePixelRatio
Type: number (1–4) — scale is an alias for devicePixelRatio. Example: 2 for 2× retina output.
theme
Type: object — Built-in keys include palette (string array, max 12), backgroundColor, fontFamily, fontSize, gridColor, mode ("light" | "dark"), textPrimary, textSecondary. Additional keys may be passed through for renderer-specific tokens (e.g. mono numerals) where supported.
header
Type: object — eyebrow, title, subtitle (strings); badge (text, optional backgroundColor, color, borderRadius); align: "left" | "center".
legend
Type: object — display (boolean), position ("top" | "bottom"), style ("square" | "line" | "circle"), fontSize (8–20), gap (0–48).
kpiStrip
Type: array (max 12) of { label: string; value: string; color?: string }.
footer
Type: object — left, right (strings); borderTop (boolean).
dataLabels
Type: boolean — When true, merges default datalabels plugin options with display: true.
annotations
Type: array (max 20) of discriminated objects — line: type: "line", axis? ("x" | "y"), value (number), label?, color?. box: type: "box", xMin, xMax (string | number), label?, color? (use rgba in color for translucent fills).
brandKitId
Type: UUID string or preset enum — obsidian, linen, polar, studio, ember, harbor. See Brand Kits.
4. Complete working example (Obsidian portfolio)
Full JSON (same as Quick Start). Produces a 1200×840 PNG at 2× retina (scale: 2 → bitmap is 2400×1680 px; display at 1200×840 logical in HTML).
{
"type": "line",
"brandKitId": "obsidian",
"width": 1200,
"height": 840,
"format": "png",
"scale": 2,
"padding": "email",
"backgroundColor": "#0d1117",
"borderRadius": 16,
"border": { "color": "rgba(255,255,255,0.08)", "width": 1 },
"header": {
"eyebrow": "MONTHLY RECURRING REVENUE",
"title": "$29,840",
"subtitle": "↑ 16% from last month",
"badge": {
"text": "On track",
"backgroundColor": "rgba(110,231,183,0.12)",
"color": "#6ee7b7",
"borderRadius": 6
}
},
"legend": {
"display": true,
"position": "top",
"style": "line",
"fontSize": 11,
"gap": 24
},
"theme": { "textPrimary": "#f0f6fc", "textSecondary": "#6e7681" },
"data": {
"labels": ["Jan","Feb","Mar","Apr","May","Jun"],
"datasets": [
{
"label": "MRR",
"data": [14200,17800,20100,22900,25720,29840],
"borderColor": "#6ee7b7",
"backgroundColor": "rgba(110,231,183,0.07)",
"fill": true,
"tension": 0.45,
"borderWidth": 2.5,
"pointRadius": [0,0,0,0,0,5],
"pointBackgroundColor": "#ffffff",
"pointBorderColor": "#6ee7b7",
"pointBorderWidth": 2.5
},
{
"label": "Target",
"data": [15000,18500,21000,24000,27000,30000],
"borderColor": "rgba(255,255,255,0.2)",
"fill": false,
"tension": 0.45,
"borderWidth": 1.5,
"borderDash": [5,4],
"pointRadius": 0
}
]
},
"options": {
"scales": {
"x": { "grid": { "display": false }, "border": { "display": false }, "ticks": { "color": "#6e7681" } },
"y": { "grid": { "color": "rgba(255,255,255,0.05)" }, "border": { "display": false }, "ticks": { "color": "#6e7681" } }
}
},
"kpiStrip": [
{ "label": "Customers", "value": "312", "color": "#f0f6fc" },
{ "label": "ARPU", "value": "$95.70", "color": "#f0f6fc" },
{ "label": "Churn", "value": "3.2%", "color": "#f59e0b" },
{ "label": "LTV", "value": "$2,990", "color": "#f0f6fc" }
],
"footer": {
"left": "chart-output.com",
"right": "Generated Apr 2025",
"borderTop": true
}
}curl
curl -X POST https://chart-output.com/api/v1/render \
-H "Authorization: Bearer pk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
--output portfolio.png \
-d @- <<'SPEC'
{
"type": "line",
"brandKitId": "obsidian",
"width": 1200,
"height": 840,
"format": "png",
"scale": 2,
"padding": "email",
"backgroundColor": "#0d1117",
"borderRadius": 16,
"border": { "color": "rgba(255,255,255,0.08)", "width": 1 },
"header": {
"eyebrow": "MONTHLY RECURRING REVENUE",
"title": "$29,840",
"subtitle": "↑ 16% from last month",
"badge": {
"text": "On track",
"backgroundColor": "rgba(110,231,183,0.12)",
"color": "#6ee7b7",
"borderRadius": 6
}
},
"legend": {
"display": true,
"position": "top",
"style": "line",
"fontSize": 11,
"gap": 24
},
"theme": { "textPrimary": "#f0f6fc", "textSecondary": "#6e7681" },
"data": {
"labels": ["Jan","Feb","Mar","Apr","May","Jun"],
"datasets": [
{
"label": "MRR",
"data": [14200,17800,20100,22900,25720,29840],
"borderColor": "#6ee7b7",
"backgroundColor": "rgba(110,231,183,0.07)",
"fill": true,
"tension": 0.45,
"borderWidth": 2.5,
"pointRadius": [0,0,0,0,0,5],
"pointBackgroundColor": "#ffffff",
"pointBorderColor": "#6ee7b7",
"pointBorderWidth": 2.5
},
{
"label": "Target",
"data": [15000,18500,21000,24000,27000,30000],
"borderColor": "rgba(255,255,255,0.2)",
"fill": false,
"tension": 0.45,
"borderWidth": 1.5,
"borderDash": [5,4],
"pointRadius": 0
}
]
},
"options": {
"scales": {
"x": { "grid": { "display": false }, "border": { "display": false }, "ticks": { "color": "#6e7681" } },
"y": { "grid": { "color": "rgba(255,255,255,0.05)" }, "border": { "display": false }, "ticks": { "color": "#6e7681" } }
}
},
"kpiStrip": [
{ "label": "Customers", "value": "312", "color": "#f0f6fc" },
{ "label": "ARPU", "value": "$95.70", "color": "#f0f6fc" },
{ "label": "Churn", "value": "3.2%", "color": "#f59e0b" },
{ "label": "LTV", "value": "$2,990", "color": "#f0f6fc" }
],
"footer": {
"left": "chart-output.com",
"right": "Generated Apr 2025",
"borderTop": true
}
}
SPEC5. Minimal card
Smallest useful card: set backgroundColor and header.title next to a normal data block. Presence of header activates the card shell.
{
"type": "bar",
"backgroundColor": "#0f172a",
"header": { "title": "Q1 Revenue" },
"data": {
"labels": ["Jan", "Feb", "Mar"],
"datasets": [{ "label": "Sales", "data": [12000, 15000, 18000] }]
}
}6. Chart.js compatibility
Everything under type, data, and options passes through to Chart.js unchanged. You can copy any example from the Chart.js documentation and it will render correctly. Card fields sit alongside — not inside — the Chart.js config.