Render themes in your app
This guide is for app builders: your app reads themes people have published and renders content their way. (Want to publish a look — a person’s, a brand’s, or your app’s own? See Publish a theme.) It uses the AT Protocol directly with @atproto/api; there’s no SDK of ours to learn.
Your app does two things: generate CSS from a theme record, and write it into the page as a data-mode-targeted rule inside a cascade layer. The generation is a small function (typed below); where the bytes go is a <style> you render (server-side, or appended once the viewer signs in).
The app is the orchestrator — it writes every data-mode marker, so it knows every expression on the page and when each source’s CSS becomes available. There are three sources, and they arrive at three different lifecycles:
| Source | Layer | When it’s known | Placement |
|---|---|---|---|
| The app’s own look | app |
build / app load | one page-global baseline |
| Each author’s look | author |
as the app renders that post | scoped to that post (it holds the DID + container right then) |
| The viewer’s look | viewer |
once the viewer signs in (maybe after first paint) | one page-global override, appended once |
The key one is the middle: an author’s CSS is produced in the same step that writes the author’s post, because that’s exactly when the app has the author’s DID and the post’s container in hand. The cascade (@layer + inheritance) reconciles the three whenever each arrives — they never coordinate.
1. Identify the viewer
Section titled “1. Identify the viewer”Get the viewer’s DID via the same AT Protocol OAuth sign-in shown in Publish a theme: they authorize your app, and the session identifies them (agent.assertDid).
2. Read a theme and render it to CSS
Section titled “2. Read a theme and render it to CSS”Fetch the record, then turn it into CSS: for each aspect the theme carries, one rule (Rule 1 names, Rule 4 selector) inside the source’s cascade layer, plus each font’s @font-face at the top level.
import { AtpAgent } from "@atproto/api";
type Owner = "app" | "author" | "viewer"; // the cascade layers, least- to most-preferred// The parts of a theme record the renderer reads (full shape → Schema reference).type ThemeRecord = { aspects: Record<string, unknown>; // → scoped variables fonts?: Array<{ family: string; src?: string }>; // → document-global @font-face};
// getRecord must be sent to the PDS that HOSTS the repo — a PDS serves only its own// repos, so the viewer's, an author's, and the app's themes each come from their owner's// host. Resolve each DID's document to its PDS endpoint first; themes are public records,// so no auth is needed. (This resolves did:plc; a did:web resolves via its// /.well-known/did.json instead — @atproto/identity's IdResolver handles both.)const agents = new Map<string, AtpAgent>();async function agentFor(did: string): Promise<AtpAgent> { let agent = agents.get(did); if (!agent) { const doc = await fetch(`https://plc.directory/${did}`).then((r) => r.json()); const pds = doc.service.find((s: any) => s.id === "#atproto_pds").serviceEndpoint; agents.set(did, (agent = new AtpAgent({ service: pds }))); } return agent;}
// Resolution is a DIRECT LOOKUP: the expression is the record key, so you fetch the// theme at that key — from its owner's PDS. The viewer's default look (what the Mode// Contract calls their "baseline") is just the theme at key "default"; a concept scope// wants the theme at that concept's key (e.g. "critical"). No listing-and-scanning.async function getTheme(did: string, expression: string): Promise<ThemeRecord | null> { const agent = await agentFor(did); return agent.com.atproto.repo .getRecord({ repo: did, collection: "place.mode.standard.theme", rkey: expression }) .then((res) => res.data.value as ThemeRecord) .catch(() => null); // 404 — no theme at that key}
const viewerDefault = await getTheme(viewerDid, "default");// the viewer's default is page-global → fonts and rules both go to <head>, nothing scopedconst css = viewerDefault ? fontFaces(viewerDefault) + themeToCss(viewerDefault, "default", "viewer") : "";themeToCss walks the theme, kebab-cases each path into a --variable, and wraps each aspect in a rule that matches its data-mode marker — the whole look or that aspect alone:
const kebab = (s: string) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
// Walk an aspect subtree and return one `--var: value;` line per leaf. Pure recursion —// every leaf under an aspect is a variable, no special cases.function collectVars(node: Record<string, any>, path: string[] = []): string[] { return Object.entries(node).flatMap(([k, v]) => v && typeof v === "object" ? collectVars(v, [...path, k]) : [` --${[...path, k].map(kebab).join("-")}: ${v};`], );}
// record → its @font-face rules. A @font-face is document-global wherever it's declared, so// it just needs to sit at the TOP LEVEL of a style block, OUTSIDE any @scope (never nested in// one). Kept OUT of themeToCss so the scopable rules can be wrapped in @scope while the fonts// sit right beside them, un-scoped. (Two posts sharing a family each declare it; browsers load// a given src once, so that's harmless — dedupe only if you care to.)function fontFaces(record: ThemeRecord): string { return (record.fonts ?? []) .map((f) => `@font-face { font-family: "${f.family}"; src: url("${f.src}"); }`) .join("\n");}
// record → its layered variable rules. `expression` is the record's key (the marker to// match); `owner` is the @layer that ranks this source (app | author | viewer). For each// aspect the theme carries, one rule targets the whole look ([data-mode="dark"]) or that// aspect alone ([data-mode~="dark/color"]); :where() keeps specificity flat so layer order// decides. Generic over aspects — reads the `aspects` container, so new aspects emit for free.//// PLACEMENT-AGNOSTIC by design: the output holds no @scope and no @font-face, so the SAME// string is correct wherever you put it — append it to <head> for a page-global source (app,// viewer), or wrap it in `@scope (…) { … }` to contain it to one subtree (an author's post,// below). Where it lands is the caller's call, not this function's.function themeToCss(record: ThemeRecord, expression: string, owner: Owner): string { const rules = Object.entries(record.aspects ?? {}) .map(([aspect, tree]) => { const vars = collectVars({ [aspect]: tree }); // keep the aspect key so names stay full: --color-… // bare marker (whole look) OR its aspect fragment (Rule 4). const sel = `[data-mode="${expression}"], [data-mode~="${expression}/${aspect}"]`; return ` :where(${sel}) {\n${vars.join("\n")}\n }`; }) .join("\n");
return `@layer ${owner} {\n${rules}\n}`;}For theme.aspects.color.surface.primary.background this yields --color-surface-primary-background inside :where([data-mode="dark"], [data-mode~="dark/color"]) — every conforming renderer produces the same names, which is what lets a theme move between apps. Fonts come from fontFaces(record) and are written globally, once, wherever the theme’s text lands.
3. Mark the scopes and write the CSS
Section titled “3. Mark the scopes and write the CSS”Declare the layer order once, mark each scope with a data-mode attribute whose value is a theme’s expression — its record key (that’s the whole matching key — data-mode="dark" picks up the theme saved at key dark), and drop each source’s CSS into a <style>. The marker is plain markup; the variables land on the marked element and inherit inward — never :root.
<style>@layer app, author, viewer;</style> <!-- precedence, declared once: viewer wins --><style>/* fontFaces(appDefault) + themeToCss(appDefault, "default", "app") — the app's own default, the fallback */</style>
<body data-mode="default"> <!-- the page-root scope: the app's default and the viewer's default both live at key "default", so both target it; the viewer's wins by layer, the app's fills any gap. --> <section data-mode="easter/color"> <!-- just this section: Easter's colors; the baseline's typography keeps inheriting --> </section></body>The viewer’s CSS from step 2 goes in the viewer layer, so it wins over the app’s default wherever they meet on the same marker. Because it’s personal and arrives after login, you render it server-side into the page (no flash) or append it once:
const style = document.createElement("style");style.textContent = css; // fontFaces(viewerDefault) + themeToCss(viewerDefault, "default", "viewer")document.head.append(style); // layer order decides — placement in the DOM doesn'tThat single append is the whole runtime cost. It doesn’t matter where it lands or when — the @layer order ranks the sources and the [data-mode] markers the author already wrote route it to every matching scope. Fill-through and nesting (Rules 5, 8) are then just the cascade: a data-mode="default" scope takes whatever the winning layer sets and inherits the rest.
Which theme, and whose
Section titled “Which theme, and whose”You don’t hand-pick a winner per scope — you emit each source into its layer and let the cascade resolve. To honor the viewer over the app’s or author’s look for the same concept, fetch the viewer’s theme at that expression’s key (getTheme(viewerDid, concept)) and render it into the viewer layer; because that layer outranks author and app, it wins per variable, and anything it doesn’t set falls through. The precedence and exact record-key matching are the Mode Contract’s Rule 5; the expression is the data-mode key, which is the record key.
Rendering authors’ looks: feeds and containment
Section titled “Rendering authors’ looks: feeds and containment”Everything above covers two of the three sources: your app’s look and the viewer’s. The third — content authors, each post dressed in its author’s own look — brings a problem of its own: every author’s default shares one marker and one layer, so each look must be contained to its own content (Mode Contract, Rule 7). That’s its own guide: Feeds and containment — it reuses getTheme, fontFaces, and themeToCss exactly as defined here.
Optional: a shared gateway
Section titled “Optional: a shared gateway”A single published theme is a pure function of a public record, so instead of every app rendering it, a gateway could serve any record as a cacheable stylesheet at a stable URL — https://css.example/{did}/{expression}.css, where {expression} is literally the record key — that apps <link>. (A raw DID has colons, fine in a URL path but not a Windows filename, so a static host encodes it or uses the handle; a dynamic gateway keeps the DID.) A gateway is a trust boundary: it must emit only the known --… variables inside the @layer / [data-mode] wrapper, never arbitrary CSS.
Next steps
Section titled “Next steps”- The live demo — this guide’s functions, running in your browser on fixture themes.
- Feeds and containment — ingest authors’ looks: many on one page, each contained to its own content.
- The Mode Contract — the full rules this guide implements.
- Schema reference — every field of every record you’ll read.
- Publish a theme — your app’s own look is published the same way as a person’s.