Documentation · Themes
Styling and configuring the widget's theme
The widget ships two selectable palettes (light, dark) plus an opt-in auto mode. You can pick one with data-theme, drive it from your own light/dark toggle, or recolor individual tokens from your page CSS — all without forking the bundle.
Pick the section you need from the rail on the right.
data-theme values
data-theme accepts three values, in priority order:
light— the light palette. Default when the attribute is absent or unrecognized.dark— the dark palette.auto— follows the visitor'sprefers-color-scheme(their OS or browser setting) and flips live when they toggle it.
Anything else falls back to light with no warning — so a typo or a renamed theme (e.g. dim) keeps an old embed rendering until you update it, instead of breaking it.
light or dark when your site has its own theme toggle — the widget watches the attribute at runtime and swaps itself live, so your toggle is the single source of truth. Use auto when your site has no toggle and you want each visitor's OS to decide.Live-swap from your own toggle
The widget's shadow <style> is rebuilt in place when it changes. The rest of the panel (optimistic inserts, toasts, post form, comment tree) is untouched, so the swap is flicker-free and preserves in-flight state.
The plainest recipe: walk every mount element and set the attribute when your toggle fires.
// Your site's light/dark toggle handler.
// One attribute update is enough — the widget reacts automatically.
function setSiteTheme(next /* "light" | "dark" */) {
document.documentElement.classList.toggle("dark", next === "dark");
document
.querySelectorAll("[data-thread]")
.forEach((el) => el.setAttribute("data-theme", next));
}For a React/SPA site the component already takes a theme prop. Just pass it through:
// components/threadly.tsx — React hook binding a theme prop.
"use client";
import { useEffect, useRef } from "react";
declare global {
interface Window {
Threadly?: { mount: (el: HTMLElement) => void };
}
}
export function Threadly({
thread,
theme,
}: {
thread: string;
theme: "light" | "dark" | "auto";
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (window.Threadly) {
window.Threadly.mount(el);
return;
}
const s = document.createElement("script");
s.src = "https://threadly-widget.pages.dev/widget/0.5.4/widget.js";
s.async = true;
document.body.append(s);
s.addEventListener("load", () => window.Threadly?.mount(el), { once: true });
}, [thread]);
// Live-swap: the widget watches `data-theme` and updates its shadow
// style in place — no remount, no flicker, optimistic UI preserved.
return (
<div
ref={ref}
data-site="pk_your_public_key"
data-thread={thread}
data-api="https://threadly-api.threadly.workers.dev"
data-theme={theme}
/>
);
}Either way: no Threadly.mount() re-call, no reload, no DOM rebuild. Set the attribute and trust the observer.
Theme tokens
Every palette is a set of named design tokens:
- Colors, spacing, and fonts are exposed as CSS custom properties on the widget’s
:hostelement. - The TypeScript
Themeshape inpackages/widget/src/theme.tsis the source of truth. - Full token list and what each one controls:
| CSS custom property | What it controls |
|---|---|
| --threadly-font | font — UI font stack |
| --threadly-font-mono | fontMono — Monospace stack (footer wordmark) |
| --threadly-font-size | fontSize — Base text size |
| --threadly-text | text — Primary text — comment body, headings |
| --threadly-muted | muted — Timestamps, status pills, secondary labels |
| --threadly-divider | divider — Hairline between rows |
| --threadly-border | border — Form + menu borders |
| --threadly-surface | surface — Popover / menu background (above the page) |
| --threadly-accent | accent — Submit button, author highlight, link color |
| --threadly-on-accent | onAccent — Text on top of `accent` fills |
| --threadly-danger | danger — Error toasts, active downvote, delete confirm |
| --threadly-upvote | upvote — Active upvote ("like") highlight |
| --threadly-you | you — "you" badge on the viewer's own comments |
| --threadly-radius | radius — Corner rounding on cards/forms |
| --threadly-gap | gap — Base spacing unit |
Why CSS custom properties? They cross the Shadow DOM boundary (custom properties are exempt from all: initial), which is what makes overrides from your page CSS work — see the next section.
Overriding tokens from your CSS
Your selectors cannot reach inside the widget's Shadow DOM, but CSS custom properties do cross the boundary — :host inherits them from the host element. So setting --threadly-muted on the mount<div> reaches into the widget's own rules and changes how the muted text is drawn.
:host, and :host counts as a class selector (specificity 0,1,0). A page-level rule with [data-thread] alone ties and loses by source order — the widget's <style> is inserted after page CSS, so its declarations win. Use enough specificity to outrank the host pseudo-class (e.g. html [data-thread], specificity 0,1,1) or apply the override as an inline style="…" on the mount element (specificity 1,0,0,0). /* page.css — recolor the widget without forking the bundle.
The Shadow DOM is impermeable to your selectors, but CSS custom
properties CROSS the boundary because :host inherits them. So you
CAN override tokens from page CSS — the only gotcha is specificity
(see the note below). */
/* The widget sets defaults on :host, so use enough specificity to
win against the host pseudo-class. A single attribute selector
([data-thread]) ties with :host (both 0,1,0) and loses by source
order because the widget's <style> is inserted after page CSS. */
html [data-thread] {
/* Slightly darker muted text on a light page — easier to read. */
--threadly-muted: #52525b;
/* Tighter accent so it doesn't dominate the page's chrome. */
--threadly-accent: #1e3a8a;
}The selector has to land on the host element itself. body rules don’t help (inherited values lose to :host's direct declarations), and inner classes (e.g. .threadly-comment) are blocked by the Shadow DOM.
For a one-off tweak, an inline style on the mount element is simplest:
<!-- Inline style works as a one-off override too:
an inline style has specificity 1,0,0,0 and beats :host (0,1,0)
regardless of source order. Handy for a single page where you
want one widget, one tweak. -->
<div
data-thread="my-post"
data-api="..."
data-site="..."
style="--threadly-muted: #52525b; --threadly-accent: #1e3a8a"
></div>What you can't restyle
The Shadow DOM is a hard wall. Your selectors cannot target the widget's internals, and the widget's selectors cannot reach your page. In particular, you can't:
- Change component structure (e.g. remove the avatar, group votes differently, fold the reply form into the row). Forks are the only answer there.
- Apply
!importantfrom your page to widget internals — selector reachability is the boundary, not specificity. - Inject custom children into the widget's DOM.
What you can do, in order of how much control you want:
- Set
data-themetolight,dark, orauto. - Override individual tokens from your page CSS to recolor or resize.
- Fork the widget if you need structural changes — see
packages/widget/src/theme.tsfor the source-of-truth tokens and follow the "copy darkTheme, register new value, publish a fork" pattern.