Customization cookbook
Recipes for making an IsItStillUp status page look and behave like yours: the CSS hooks the rendered page exposes, the branding keys behind the Settings form, embeddable widgets and badges, and the feed, subscribe and MCP URLs your customers (and their agents) can point at. Everything here works on a page that renders without JavaScript — because that is what IsItStillUp serves.
Where custom CSS goes
Settings → Branding → Custom CSS (Pro and above). What you save is emitted in its own <style> block after IsItStillUp's own stylesheet, so plain selectors win without !important and you never have to fight specificity. It is capped at 20KB, any </style sequence is stripped so the block can't be closed early, and the whole thing is re-checked at render time — a page that drops off the Pro plan simply stops emitting it, with nothing to clean up.
Changes land on the next publish. Anything else that edits the page (an incident update, a component status change, saving Branding) triggers one, so a save here is usually visible within seconds.
Colors: prefer the palette fields in Settings over hard-coded hex values in CSS — they set the same variables in both light and dark mode. See Brand palette.
Class reference
The rendered page uses short, stable class names. These are the ones worth targeting — every selector below appears in the stylesheet IsItStillUp emits:
| Selector | What it is |
|---|---|
| .wrap | The page container (max 760px). Everything below is a direct child of it. |
| .hd / .hd img / .sub | Header row, the logo image inside it, and the page description. |
| .blinks | Your header links row. |
| .banner | Overall status banner. Adds .b-minor, .b-major, .b-critical or .b-maintenance. |
| .card | Shared surface: the components list, each incident, the subscribe form. |
| .cl / .cl > li / .grp / .kid | Components list, one row per component, group headings, nested children. |
| .crow / .cname / .cstat / .cdesc | Inside a component row: layout, name, status text, description. |
| .dot, .s-operational … | Status dot; the .s-{status} class carries the color. |
| .bars / .bars i / .ax | 90-day uptime strip; each day is an <i> with .u, .w, .d or .n; .ax is the caption row. |
| .lat / .latl | Response-time sparkline and its label. |
| .inc | One incident. Adds .crit, .maint or .done. .pill is the status chip, .meta the timestamps. |
| .tl | The update timeline inside an incident. |
| .pm / .pmbody | Postmortem disclosure and its rendered body. |
| .ann | An announcement block. |
| .day / .none | A day in the history section; the empty-state line. |
| .sub-form / .row | Subscribe form and its input row. |
| .hp | Spam honeypot. Never make this visible — real visitors would fill it in and be rejected. |
| .langs / .rv | Language switcher; the “restricted view” chip on an audience link. |
| footer | Feed links, last-updated stamp, and the IsItStillUp credit on free plans. |
Section headings (Components, Subscribe, …) are plain <h2> elements with no class of their own — target them relative to the block they introduce, as the recipes below do.
Hide a section
/* Hide the subscribe card and its heading */
.sub-form { display: none; }
h2:has(+ .sub-form) { display: none; }
/* Other things people hide */
.langs { display: none; } /* language switcher */
.lat { display: none; } /* response-time sparklines */
.cdesc { display: none; } /* component descriptions */:has() is supported by every current browser; an older one simply keeps showing the heading above a hidden form, which is why the form itself is hidden with a plain selector. If you want the subscribe box gone for everyone, turn the channels off in Settings → Subscription channels instead — that removes it from the HTML and makes the endpoint refuse new subscriptions, rather than hiding it client-side.
Reorder sections
/* Every section is a direct child of .wrap, so flexbox order works.
Anything you don't name keeps order: 0 and stays above the rest.
Headings are separate elements — move them with :has(). */
.wrap { display: flex; flex-direction: column; }
.wrap > h2:has(+ .cl),
.wrap > .cl { order: 1; } /* components */
.wrap > h2:has(+ .day),
.wrap > .day { order: 2; } /* history */
.wrap > h2:has(+ .sub-form),
.wrap > .sub-form { order: 3; } /* subscribe */
.wrap > footer { order: 4; }Order only moves things visually; the DOM order — and therefore reading order for screen readers and crawlers — is unchanged. Keep the status banner first: it is the one fact every visitor came for.
Custom fonts
/* @import must be the first thing in your custom CSS — it's emitted as its
own <style> block, after IsItStillUp's, so this is a valid position. */
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap");
body { font-family: "Inter", ui-sans-serif, -apple-system, sans-serif; }
h1, h2, .banner { letter-spacing: -0.01em; }A webfont is a third-party request on your status page: if that host is slow, your page is slow at exactly the wrong moment. display=swap keeps text readable while it loads, and a real fallback stack keeps the page legible if the font never arrives. Self-hosting the file (any https URL works in @font-face) is better still.
Brand palette
Settings → Branding → Palette writes branding.accent plus branding.palette.*, and the renderer turns each key into one CSS variable override on the public page:
| Branding key | CSS variable | Controls |
|---|---|---|
| accent | --accent | Operational banner, buttons, healthy uptime bars. |
| palette.background | --ground | Page background. |
| palette.surface | --surface | Cards. |
| palette.text | --ink | Text. |
| palette.link | --link | Links. |
| palette.border | --line | Borders. |
Status colors — amber for degraded, red for down — are deliberately not brandable. A palette that makes an outage look calm is a palette that costs your customers money.
Setting the same variables by hand, e.g. to ship a different dark mode:
/* The palette writes these for you. Set them by hand only if you want
something the form can't express — e.g. a different dark-mode palette. */
:root {
--ground: #0b1220; /* page background */
--surface: #131c2e; /* cards */
--ink: #e6edf6; /* text */
--line: #22304a; /* borders */
--accent: #4cc4a4; /* banner, buttons */
}Or over the API, where branding merges key by key:
curl -sS "$BEACON_URL/api/v1/pages/$PAGE_ID" \
-H "Authorization: Bearer $BEACON_API_KEY" -H "Content-Type: application/json" \
-X PATCH -d '{
"branding": {
"accent": "#4cc4a4",
"palette": { "background": "#0b1220", "surface": "#131c2e" },
"layout": "cover",
"headline": "All of Acme, one page"
}
}'Widget and badges
The status pill is a dependency-free script that polls your page's public JSON and renders a small fixed pill. Every option is an attribute on the tag — there is no config endpoint, so the file stays cacheable and keeps working when the rest of IsItStillUp is having a bad day. Change an option, re-copy the tag.
<script src="https://www.isitstillup.com/widget.js" data-page="acme" data-position="bottom-right" data-maintenance="1" async></script>data-page— your page slug (required).data-position—bottom-right(default),bottom-left,top-right,top-left.data-always—"1"keeps the pill up when everything is operational. By default it only appears when something is wrong.data-accent— hex color for the dot while operational. Degraded and outage states keep their own colors.data-maintenance—"1"adds the next scheduled maintenance window under the status line.
Visitors can dismiss the pill; that is remembered for their browser tab only (sessionStorage), so it comes back on the next visit. Any error — network, parse, blocked storage — is swallowed: the widget must never break the page it is embedded in.
Per-component badges are SVG, cached at the edge, and safe in a README. Swap COMPONENT_ID for a component id from the dashboard or GET /api/v1/pages/{pageId}/components:
<img src="https://www.isitstillup.com/api/badge/COMPONENT_ID" alt="Service status" height="20">The sparkline style adds a 90-day uptime strip beside the status text, using the same healthy/degraded/down thresholds the page itself uses:
<img src="https://www.isitstillup.com/api/badge/COMPONENT_ID?style=sparkline" alt="Service status" height="20">Feeds and JSON
Every public page publishes the same facts in several shapes. None of them need a key:
| URL | What it is |
|---|---|
| /s/{slug}/rss.xml | RSS 2.0 incident feed. |
| /s/{slug}/atom.xml | The same feed as Atom, for readers that prefer it. |
| /s/{slug}/maintenance.ics | iCal feed of upcoming and active maintenance windows — subscribe to it in a calendar app. |
| /s/{slug}/history | Human-readable incident history. |
| /api/v2/pages/{slug}/status.json | Overall indicator + description. The smallest thing to poll. |
| /api/v2/pages/{slug}/summary.json | Status, components, active incidents, scheduled maintenance, announcements. |
| /api/v2/pages/{slug}/incidents.json | The last 50 published incidents with their updates. |
The JSON endpoints are Statuspage-shaped and CORS-open, so an existing dashboard widget usually works by changing the hostname. A password-protected or IP-restricted page gates these exactly like its HTML: the feeds are part of the page, not a back door around it.
Per-incident subscribe link
Beyond the page-wide subscribe form, a visitor can ask to hear about one incident — useful in a support reply or a banner in your own app. Same double opt-in, and the subscription retires itself when the incident resolves, so there is nothing to clean up.
<!-- "Notify me about THIS incident only" — same double opt-in as the
page-wide form. incidentId comes from summary.json / the REST API. -->
<form method="POST" action="https://status.example.com/api/public/subscribe-incident">
<input type="hidden" name="pageSlug" value="acme">
<input type="hidden" name="incidentId" value="INCIDENT_ID">
<label for="beacon-incident-email">Email</label>
<input id="beacon-incident-email" type="email" name="email" required>
<!-- honeypot: keep it hidden and empty -->
<input type="text" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" style="display:none">
<button type="submit">Notify me</button>
</form>Requires the Per-incident updates channel in Settings → Subscription channels. The same endpoint accepts JSON ({"pageSlug","incidentId","email"}) if you would rather post it from your own backend; it shares one rate-limit budget per IP with the page-wide form.
Public MCP server
Every status page also answers as a read-only MCP server, so an agent can ask about your status the same way a person reads the page. It serves from the pre-rendered artifacts, never the database — which is the point: it keeps answering during exactly the outages people are asking about.
# Public, read-only, no API key: one MCP server per status page.
curl -sS https://status.example.com/s/acme/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# Ask it the question everyone asks during an outage:
curl -sS https://status.example.com/s/acme/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"get_status","arguments":{}}}'Stateless MCP Streamable HTTP (JSON-RPC 2.0 over a single POST — no SSE, no session ids). Tools: get_status, get_incidents, get_maintenance. The authenticated admin server, which can open and update incidents, is documented in the API reference. Depending on someone else’s MCP server instead? See MCP status.