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.
| Path | Your stylesheet is attached… |
|---|---|
| Content widget, server-rendered page | in the page <head> and inside your shadow root |
| Content widget, CSR embed | inside your shadow root only |
| Layout shell, server-rendered page | in the page <head> only — the shell is light DOM |
| Layout shell, CSR embed | inside 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:
- Namespace every class.
.sfacts,.pdpf-region,.acme-weather-card— a prefix you own. Never.card,.title,.grid. - Never ship a global reset. See the Tailwind section below; this is the one that bites.
: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.
| Attribute | Means | Set by |
|---|---|---|
data-hr-theme="light|dark" | the resolved theme; JS owns theming from here | the 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 more | the 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
: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.:where()holds specificity at zero, so the dark selector must string-match the light one.:where(:host([data-hr-theme="dark"])) .priceloses 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’scustomCsskeep winning, so do not drop it.- 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
autopaints the light value and flashes at hydration. - Never resolve
autoon 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>takescss(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 returnsnullwhenshowis false ortitleis empty. It is a heading, not a wrapper — no children, nooptionsprop.resolveWidgetSpacingStyleaccepts raw or parsed options (it runs the legacy-widthlift itself). Never hand-roll CSS from thespacingobject.<Stylesheet>,<WebFont>and<WidgetSection>are scaffold components undersrc/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
gapcontrol. 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.fontson 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.familyfrom 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.
Related
- Shadow-root lifecycle, CSS URL precedence, and the
manifest.shadowDOM-vs-mount()disagreement: Runtime, mount and the DOM contract - Dialogs and the elevated portal’s token trap: Portals and dialogs
mediaSafeAutoand every other summary field: Manifest: widget summary- Layout CSS rules, the no-background product rule and slots: Layout widgets
- Symptom-first fixes for unstyled widgets, missing fonts and light-only dark mode: Troubleshooting