Portal Customization & Theming
How to change how a portal looks — from config only. Three layers, in order of preference:
- Theme tokens — colors, fonts, shape, layout. Covered in PORTAL_JSON_COMPONENTS.md §2. Prefer these; they adapt light/dark automatically and never break.
custom_css— a free-form CSS escape hatch in the theme. Powerful, but global and CSS-only (see limits below).- New block types — anything the two above can't express needs a renderer change (code), not config.
This page documents layers 2 and 3: what custom_css can target, the stable
CSS hooks, and the hard limitations.
Backgrounds & art direction
For rich hero/page backgrounds — gradients, mesh blobs, grids, grain, images,
pinned decorations, a readability scrim, and a slow CSS drift — use the
declarative theme.background spec rather than custom_css. It is a layer
stack (theme layer 1, config only), reskins with the theme via token refs, and
renders identically in edit, preview, and publish. Build it from the composer's
Theme → Background panel or author the JSON directly.
Schema + examples: PORTAL_JSON_COMPONENTS.md §2.7. Design rationale (and the coding-agent authoring guide): ART_DIRECTION_SPEC.md.
Reach for custom_css (below) only for looks the background spec can't express.
custom_css
Set theme.custom_css to a string of CSS. It is injected into every rendered
portal page (builder preview, published build, custom domain).
{
"apifleet_portal": 1,
"name": "Acme",
"theme": {
"mode": "dark",
"custom_css": ".card { border-radius: 12px; } .card-grid { gap: 14px; }"
},
"pages": [ ... ]
}
What it can do: restyle any rendered element via the class hooks below, override theme CSS variables, add gradients/shadows/spacing, tweak hover states.
Safety: the renderer neutralizes a </style> breakout and does not accept
custom JavaScript. CSS can still make network requests through url(...),
hide or reposition interface elements, and affect the whole portal. Treat
custom_css as trusted-editor content and use only trusted asset URLs.
Theme CSS variables you can override
custom_css can reassign any token the renderer reads (full list in the
components doc). Common ones:
:root {
--brand: #2b5cff; /* primary */
--surface: #15181c; /* cards / panels */
--text: #e8ecf1;
--radius: 14px; /* corner radius */
--card-icon-img: ...; /* etc. */
}
Prefer setting these via theme tokens (primary, surface, radius, …)
rather than custom_css — tokens are validated and theme-aware.
CSS hook reference
Stable class names the renderer emits, by block. Target these in custom_css.
(Source of truth: app/render.py + app/static/portal.css.)
Page chrome
| Hook | Element |
|---|---|
.prose |
wrapper around rendered Markdown (h1–h6, p, ul, table, blockquote…) |
.doc-topbar, .doc-brand, .doc-cta |
header bar, logo, CTA button |
.doc-tabs, .doc-tab |
secondary nav tab strip |
.doc-sidebar, .doc-nav, .nav-link, .nav-icon |
left nav + per-item icon |
.doc-content |
main content column |
.doc-footer, .doc-page-meta |
footer, "was this helpful" row |
card_grid
| Hook | Element |
|---|---|
.card-grid |
the grid container (--cols custom prop = column count) |
.card |
one card |
.card-icon |
emoji / text icon span |
.card-icon.material-symbols-outlined |
Material-font icon span |
.card-icon-img |
icon_url image (24×24) |
.card-media |
image hero wrapper |
.card-title, .card-link |
card heading + stretched link |
.card-desc |
card description (rendered Markdown) |
callout
| Hook | Element |
|---|---|
.callout |
container |
.callout-info / .callout-warning / .callout-success |
variant modifier |
.callout-icon, .callout-body |
icon, body |
code
| Hook | Element |
|---|---|
.doc-code-block |
container |
.cb-bar, .cb-title, .cb-lang, .cb-copy, .cb-panel |
title bar, language tag, copy button, code panel |
tabs
| Hook | Element |
|---|---|
.content-tabs, .ct-bar, .ct-panels |
tab set, tab bar, panels |
Spec blocks (endpoint, schema, endpoint_list)
| Hook | Element |
|---|---|
.endpoint, .ep-head, .ep-title, .ep-path, .ep-main, .ep-aside |
endpoint block parts |
.ep-list, .ep-item, .ep-item-sum |
endpoint_list rows |
.schema-table, .prop, .prop-name, .ptype, .pdesc |
schema table |
.code-sample, .cs-bar |
per-language request samples |
divider
.block-divider.
Icons
.material-symbols-outlined — Material Symbols font span. Content is the icon
name (e.g. rocket_launch). ~4,300 names in app/static/material-symbols.json.
Hard limitations (read before designing)
These are the boundaries a config author will hit. None are bugs — they're the shape of the system today.
-
custom_cssis global. It applies to the whole portal. There is no per-block class or id, so you cannot style onecard_griddifferently from another, or onecalloutdifferently from the next. Everything of a type shares the look. (Workaround: style by position with:nth-of-type, which is brittle.) -
Blocks are siblings, not nestable. Each block renders as its own top-level element in
.doc-content. You cannot wrap several blocks in one container — e.g. put a heading inside the same panel as acard_grid. A "step number + cards in one window" layout places the heading above the panel, not within it. -
No custom JavaScript. Published portals load only the fixed
portal.js(nav, tabs, copy buttons, search overlay, try-it).custom_cssis CSS-only. So bespoke interactions, canvas effects, or third-party widgets are not possible from config. (Rich backgrounds — gradients, mesh, grain, images, decorations, and a slow CSS drift viatheme.background"animated": true— are the supported way to get motion without JS.) -
One theme per portal.
mode(light/dark/system) and all tokens are portal-wide. You can't set a different theme per page. -
Published builds are immutable. A theme/CSS change takes effect on the next publish; already-published builds keep their snapshot until republished.
When a design needs something on this list, it's a new block type (renderer change), not config.
Recipe: stepped cards (like a "01 · Build" section)
Achievable config-only — structure + static panel, no animation:
{ "type": "markdown", "config": { "source": "### 01 · Build" } },
{ "type": "card_grid", "config": { "columns": 1, "cards": [
{ "title": "Payment products", "description": "Compare your options.", "href": "/overview", "icon": "credit_card" },
{ "title": "Webhooks", "description": "React to every event.", "href": "/webhooks", "icon": "bolt" }
] } }
/* theme.custom_css */
.card-grid { gap: 14px; padding: 22px; border-radius: 16px;
background: linear-gradient(135deg, #2a3350, #1b2740 55%, #223a4d); }
.card { background: #15181c; border: 1px solid #23262b; border-radius: 12px; }
.card-icon.material-symbols-outlined { width: 44px; height: 44px;
border-radius: 999px; background: #1e2330; color: #8fb0ff;
display: inline-flex; align-items: center; justify-content: center; }
Caveat (limitation #1): this styles every card grid in the portal. For a
per-section treatment or an animated background, a dedicated steps block is
the right path.