Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Styling and theming

Two things decide how a plugin widget looks: where your stylesheet is attached, and which host attribute says “dark”. Get those right and the rest is ordinary CSS. Get them wrong and you either repaint the customer’s whole page or flash light before going dark. The field rules this page depends on stay on the contract pages, linked rather than repeated.

Where your CSS actually lands

A shadow root protects your widget from the customer’s CSS. It does not protect the customer from yours.

PathYour stylesheet is attached…
Content widget, server-rendered pagein the page <head> and inside your shadow root
Content widget, CSR embedinside your shadow root only
Layout shell, server-rendered pagein the page <head> only — the shell is light DOM
Layout shell, CSR embedinside the layout’s shadow root

The <head> half surprises everyone. On a composed page the renderer collects every widget’s resolved assets.css, de-duplicates the list, and the edge worker appends one <link rel="stylesheet"> per entry to <head>:

// src/lib/homerunner/cf-worker.ts — buildCssHtml(), fed by the renderer's
// combined, de-duplicated assets.css.
return (assets.css || [])
  .map(function (href) { return '<link rel="stylesheet" href="' + escapeHtml(href) + '">'; })
  .join("");

That is deliberate: on browsers that do not get Declarative Shadow DOM the server-rendered markup is plain light DOM until mount() runs, and it has to be styled meanwhile. The consequence is the same either way — on a server-rendered page every selector you ship is a global selector. Three rules follow, for content widgets exactly as much as for layouts:

  1. Namespace every class. .sfacts, .pdpf-region, .acme-weather-card — a prefix you own. Never .card, .title, .grid.
  2. Never ship a global reset. See the Tailwind section below; this is the one that bites.
  3. :host(...) rules are the one form that self-scopes — outside a shadow tree they are valid CSS matching nothing, so they are inert in <head> and live in the shadow root.

Layouts add a fourth rule: never paint a background on the shell. That rule, and why a :host-only stylesheet is inert on every server-rendered page, are normative in Layout widgets.

The Tailwind stack, and why a suite ships none of it

Single-widget only. create-hr-plugin --template single ships Tailwind 4 through @tailwindcss/vite, plus tw-animate-css, clsx + tailwind-merge behind a cn() helper, Radix primitives and lucide-react. There is no tailwind.config.js; v4 is configured in CSS.

/* packages/create-hr-plugin/template/src/widget.css (first two lines) */
@import "tailwindcss";
@import "tw-animate-css";

Suite. --template suite ships none of that (create-hr-plugin 0.8.1) — no Tailwind, no tailwindcss() Vite plugin, no clsx/tailwind-merge, no Radix, no lucide-react, and no src/components/ui/ kit. Each widget gets a hand-written, class-prefixed {slug}.css instead, which is the same choice the reference suite made. The reason is the preflight problem below: a suite is expected to carry a layout, and a layout’s stylesheet is loaded document-level and unisolated on a server-rendered page, so a framework’s global reset lands on the customer’s whole document. Nothing stops you adding Tailwind to a suite yourself — everything in this section then applies, per widget. Build a suite has the file-by-file conversion.

Utilities work unchanged inside a shadow root because Tailwind 4 emits its theme variables on :root, :host — confirm it on any built plugin stylesheet with grep -o ':root[^{]*{' dist/acme-weather.css. Two things do not carry over from a normal Tailwind app.

dark: is the wrong signal. Tailwind’s built-in dark variant compiles to @media (prefers-color-scheme: dark) — the visitor’s OS preference, not the widget’s configured colour scheme. A customer who explicitly picks dark gets light utilities; one who picks light gets dark ones on a dark laptop. Never use dark: for theme-driven styling; use the host-attribute contract below.

Preflight is a page-wide reset. @import "tailwindcss" pulls in preflight, whose first rule is a universal selector:

/* tailwindcss/preflight.css:7-16 */
*, ::after, ::before, ::backdrop, ::file-selector-button {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border: 0 solid;
}

Inside a shadow root that is contained. In the customer’s <head> it strips every margin, padding and border on their whole page. If your widget is server-rendered — or is a layout, which is always light DOM there — import Tailwind without preflight:

/* Preflight-free Tailwind 4. Both subpaths are real package exports
   (tailwindcss/package.json "exports"); `@import "tailwindcss"` is just
   these two plus preflight in @layer base. */
@layer theme, components, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);

Set your own box model on your namespaced root instead, then build and confirm the emitted CSS carries no *,::after,::before reset. Both the suite scaffold and the reference suite pdp-suite sidestep the whole problem by shipping no Tailwind at all.

The theme contract: data-hr-theme vs data-hr-scheme

Both attributes live on the host element — the shadow host for a content widget, the light-DOM shell <div> for a server-rendered layout. They mean different things and different actors own them.

AttributeMeansSet by
data-hr-theme="light|dark"the resolved theme; JS owns theming from herethe runtime at mount on every path — and the server, but only on a layout shell
data-hr-scheme="auto"config: “follow the OS”. A pre-hydration CSS gate, nothing morethe server only, under the conditions below

They are never both meaningful at once: the runtime removes data-hr-scheme the moment it attaches, and the media-query arm you write carries :not([data-hr-theme]) so it yields cleanly.

Why the attribute and not a wrapper class. Your stylesheet paints the host itself (:host { background: … }), and a .dark class on a descendant <div> can never change a custom property on the host — inheritance only flows down. That is why widget-core keeps a marker on the host (HOST_THEME_ATTR, runtime/host-theme.ts) instead.

Why not props.resolvedTheme. For an explicit light or dark the prop is correct. For auto it is derived from a cookie the Cloudflare worker never forwards, so it is "light" on every production server render — and because mount() hydrates with the server’s props, a component that branches on it stays light after hydration too. Every published sample plugin does this, and every one of them is light-only on a server-rendered page. Branch in CSS off the host attribute instead: attachHostThemeController also keeps that attribute live across an OS theme flip mid-session, which the React prop never does. Per-path prop details are in Component and SSR module.

Dark at first paint

Replacing the old guide’s “accept the flash or skip auto”: you can paint dark before a single byte of JavaScript runs. Two steps.

1. Dual-emit every dark rule under exactly these two prefixes, appended after the light rule — never branched, never replacing it:

/* The two prefixes required by the `mediaSafeAuto` contract in
   packages/homerunner-widget-core/src/manifest.ts. Light rule first, unchanged. */
.sfacts { background: #fff; color: #18181b; }

:where(:host([data-hr-theme="dark"])) .sfacts {
  background: #0a0a0b;
  color: #f4f4f5;
}

@media (prefers-color-scheme: dark) {
  :where(:host([data-hr-scheme="auto"]:not([data-hr-theme]))) .sfacts {
    background: #0a0a0b;
    color: #f4f4f5;
  }
}

2. Declare mediaSafeAuto: true on the widget summary (widget-core 0.12.0+). It is a promise about your CSS that nothing validates; the field rule and its enforcement level are in Manifest: widget summary.

The stamp appears only when all three hold: the resolved colorScheme is auto, the request emitted a Declarative Shadow DOM template, and the flag is true. On the legacy (non-DSD) bucket you keep the old behaviour, light until JS — correctly, because :host rules are inert without a shadow root at parse time. See How a widget renders for when DSD is emitted.

Four footguns, each of which shipped a real bug

  1. :not() must be INSIDE :host(...). :host([data-hr-scheme="auto"]:not([data-hr-theme])) works. :host([data-hr-scheme="auto"]):not([data-hr-theme]) parses fine and silently never matches a shadow host. Your dark arm is dead and nothing fails.
  2. :where() holds specificity at zero, so the dark selector must string-match the light one. :where(:host([data-hr-theme="dark"])) .price loses to .acme-card .price — same specificity contest, and the light rule may come later in the file. Repeat the light rule’s full descendant selector in the dark arm. The zero-specificity wrap is what lets a customer’s customCss keep winning, so do not drop it.
  3. Gate symmetrically. If a light rule is emitted only under a condition (an accent is set, a variant is on), the dark arm needs a branch for the “no dark value” case that restores the base values — otherwise auto paints the light value and flashes at hydration.
  4. Never resolve auto on the server. No cookies, no client hints, no UA sniffing. The server stamps configuration; the browser resolves it. Anything else poisons the shared page cache or is wrong on the first visit.

When your tokens come from JS

If you build a <style> string from options (accent, font, custom properties), emit the dark half twice from one helper so the two arms cannot drift:

// Shape of the platform's own palette hook — see the two `explorerDarkOverrides`
// calls in packages/homerunner-embeddables/widgets/explorer/hooks/useExplorerCss.ts.
// Token names are yours.
const darkTokens = (prefix: string, dc: PluginConfig["darkModeColors"]) => `
  ${prefix} .acme-card { --acme-accent: ${dc.accent}; color-scheme: dark; }
`;

const themeCss = `
  :host { --acme-accent: ${options.lightModeColors.accent}; }
  ${darkTokens(':where(:host([data-hr-theme="dark"]))', options.darkModeColors)}
  @media (prefers-color-scheme: dark) {
    ${darkTokens(':where(:host([data-hr-scheme="auto"]:not([data-hr-theme])))', options.darkModeColors)}
  }
  ${options.customCss ?? ""}
`;

Read options.darkModeColors directly, never through a resolvedTheme-gated variable — the dark arm has to ship on every render. Keep customCss last.

Theming a layout

A layout shell carries the same two attributes, but it is light DOM on a server-rendered page and a shadow host on a CSR embed. :host(...) only covers the second case, so write both arms in one forgiving selector list:

/* Layout shell: `[data-hr-theme]` matches the light-DOM wrapper on an SSR page;
   `:host([data-hr-theme])` matches the shadow host on a CSR embed. Each is inert
   on the other path. */
.pdpf-label { color: rgba(100, 100, 120, 0.9); }

:where([data-hr-theme="dark"], :host([data-hr-theme="dark"])) .pdpf-label {
  color: rgba(200, 200, 220, 0.9);
}

@media (prefers-color-scheme: dark) {
  :where([data-hr-scheme="auto"]:not([data-hr-theme])) .pdpf-label {
    color: rgba(200, 200, 220, 0.9);
  }
}

One difference from content widgets: a layout shell gets its server theme attribute without mediaSafeAuto. On a DSD-bucket request an auto shell is stamped data-hr-scheme="auto"; anything else is stamped data-hr-theme="light|dark" outright. A layout shell is the only server-rendered surface that carries a resolved data-hr-theme.

Rendering the base fields yourself

The dashboard renders the controls for spacing, colorScheme, accent colours, font, customCss and section automatically — see Config schema and UI schema. The platform applies none of them. They arrive in options and you render them.

// packages/create-hr-plugin/template/src/widget.tsx (parse + spacing, verbatim)
// plus the token/section pattern from hr-plugins/reviews-marquee/src/widget.tsx.
import { parseWidgetConfig } from "@homerunner-next/widget-core/schema";
import { resolveWidgetSpacingStyle } from "@homerunner-next/widget-core/spacing";
import { Stylesheet, WebFont, WidgetSection } from "./components/ui";

const options = React.useMemo(
  () => parseWidgetConfig(configZod, props.options) as PluginConfig,
  [props.options],
);

const themeCss = `
  :host {
    --acme-accent: ${options.lightModeColors.accent};
    font-size: ${options.font.size}px;
    font-family: ${options.font.family}, ui-sans-serif, system-ui, sans-serif;
  }
  ${/* dark arms here — see above */ ""}
  ${options.customCss ?? ""}
`;

return (
  <>
    <WebFont family={options.font.family} />
    <Stylesheet css={themeCss} />
    <div style={resolveWidgetSpacingStyle(options)}>
      <WidgetSection show={options.section.show} title={options.section.title} />
      {/* … */}
    </div>
  </>
);

Four corrections to the old guide, all in that block:

  • <Stylesheet> takes css (string or string array, joined) and renders one plain <style>. It does not de-duplicate and it does not update in place.
  • <WidgetSection> takes {show, title, className} and returns null when show is false or title is empty. It is a heading, not a wrapper — no children, no options prop.
  • resolveWidgetSpacingStyle accepts raw or parsed options (it runs the legacy-width lift itself). Never hand-roll CSS from the spacing object.
  • <Stylesheet>, <WebFont> and <WidgetSection> are scaffold components under src/components/ui/, not SDK exports — in a suite, copy them into your project.

options is raw until you parse it, on the server and in the browser (Settings and options); customCss goes last so the customer’s rules win. Every value interpolated into that template is an admin-supplied string heading for dangerouslySetInnerHTML — sanitise the accent, family and customCss before you concatenate. And if you open a dialog through the elevated portal, only <link> tags are cloned into it: this whole <style> is missing there unless you pass the same string as cssString. See Portals and dialogs.

Spacing for a layout’s slots

Not supported yet. The platform’s own layouts expose per-slot spacing and a gap control. Those fields live in a private package and a plugin layout inherits neither.

Declare your own, reusing the SDK’s schema builders so the dashboard renders the same compound control:

// Builders from packages/homerunner-widget-core/src/spacing.ts:224-268.
import { z } from "zod";
import { widgetSchema } from "@homerunner-next/widget-core/schema";
import {
  marginBoxSchema, paddingBoxSchema, widthSchema, maxWidthSchema,
  resolveSpacingStyle, DEFAULT_SLOT_SPACING,
} from "@homerunner-next/widget-core/spacing";

const slotSpacing = z
  .object({
    margin: marginBoxSchema(DEFAULT_SLOT_SPACING.margin),
    padding: paddingBoxSchema(),
    width: widthSchema(DEFAULT_SLOT_SPACING.width),
    maxWidth: maxWidthSchema(DEFAULT_SLOT_SPACING.maxWidth),
  })
  .default(DEFAULT_SLOT_SPACING);

export const configZod = widgetSchema.extend({
  slots: z.object({ hero: z.array(z.string()).default([]) /* … */ }).default({}),
  slotSpacing: z.object({ hero: slotSpacing /* … */ }).default({}),
});

Apply it with resolveSpacingStyle(cfg.slotSpacing.hero) on the region wrapper. DEFAULT_SLOT_SPACING starts fully neutral, so your stylesheet keeps winning until the customer changes something. The slots field itself is mandatory and its keys must match your manifest slot names exactly (Layout widgets); every /spacing export is listed in SDK reference.

Fonts and bundled images

@font-face is ignored inside a shadow root — a stylesheet loaded there can use a family but can never define one. Hence two channels:

  • A face your design depends on: declare it in assets.fonts on the widget summary. It is injected at document level on both paths, where the browser will honour it.
  • A family the customer picks (options.font.family from the base schema): load it at runtime with the scaffold’s <WebFont family={options.font.family} /> — browser-only, and a no-op in the SSR bundle.

Images and other files are ordinary Vite imports (import heroBg from "./hero-bg.png", or url(./x.png) in CSS). The build rewrites each reference so it resolves against wherever your bundle actually loaded from, on the client and inside the SSR sandbox alike; small files of known asset types become data: URLs and larger ones are emitted beside your bundle. Never hand-write an absolute /dist/... path. Font counts and path rules, the inline threshold, the extension list and the suite-directory requirement are all in Keywords, assets and URLs — which also lists the origins a customer’s Content-Security-Policy has to allow for your stylesheets and fonts.

While you are developing

npm run dev intercepts CSS imports from src/ so Vite never injects a <style> into document.head (a shadow root would ignore it), serves your compiled stylesheet at the URL shape mount() expects, and on save cache-busts the <link> inside the shadow root rather than doing a normal CSS hot update. You configure none of it; the routes and the HMR event name are in Keywords, assets and URLs and the sandbox itself in Dev sandbox and mocking.

The document-level <head> copy of your stylesheet you can see locally, and should. npm run dev:ssr in its default server-render mode builds no shadow root and links your stylesheet into the harness page’s own <head> — the same unisolated condition as a composed page, which makes it the local test for the three rules above. Both shapes. One caveat: that <link> comes from your getStaticAssets, and a bundle exporting none — what --template suite scaffolds — needs widget-core 0.12.3+ to get one at all; below that it previewed with an empty <head> and no CSS. Previewing SSR locally lists what that harness still cannot do.

What you cannot see locally is dark-at-parse, which needs a real DSD-capable request. Check it on a server-rendered page before you submit — reload with the OS in dark mode and JavaScript disabled.