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's prefers-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.

Which value to pick. Use 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-toggle.tsCopy to clipboard
 // 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.tsxCopy to clipboard
 // 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 :host element.
  • The TypeScript Theme shape in packages/widget/src/theme.ts is the source of truth.
  • Full token list and what each one controls:
CSS custom propertyWhat it controls
--threadly-fontfont UI font stack
--threadly-font-monofontMono Monospace stack (footer wordmark)
--threadly-font-sizefontSize Base text size
--threadly-texttext Primary text — comment body, headings
--threadly-mutedmuted Timestamps, status pills, secondary labels
--threadly-dividerdivider Hairline between rows
--threadly-borderborder Form + menu borders
--threadly-surfacesurface Popover / menu background (above the page)
--threadly-accentaccent Submit button, author highlight, link color
--threadly-on-accentonAccent Text on top of `accent` fills
--threadly-dangerdanger Error toasts, active downvote, delete confirm
--threadly-upvoteupvote Active upvote ("like") highlight
--threadly-youyou "you" badge on the viewer's own comments
--threadly-radiusradius Corner rounding on cards/forms
--threadly-gapgap 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.

Specificity gotcha. The widget declares its defaults on :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.cssCopy to clipboard
 /* 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:

index.htmlCopy to clipboard
 <!-- 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 !important from 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:

  1. Set data-theme to light, dark, or auto.
  2. Override individual tokens from your page CSS to recolor or resize.
  3. Fork the widget if you need structural changes — see packages/widget/src/theme.ts for the source-of-truth tokens and follow the "copy darkTheme, register new value, publish a fork" pattern.