dotdotduck — Theming (CSS variable customisation)
Different sites want different UI. Every visual value in dotdotduck reads from a CSS variable; hosts retheme by overriding those variables in their own stylesheet.
Design principles
- Every visual value is a variable — no hardcoded colours / radii / fonts in the SDK.
- Sensible defaults — looks good before the host configures anything.
- Light + dark out of the box — switch via
[data-theme="dark"]on a parent. - Doesn't pollute host CSS — every dddk selector is scoped under
[data-dddk-ui]. - Global CSS variables, not Shadow DOM — see Shadow DOM section at the bottom for the rationale.
Full variable list
Colors (base)
:root {
--dddk-accent: #ec4899; /* primary: highlight / button / link */
--dddk-accent-hover: #db2777; /* primary hover */
--dddk-accent-soft: rgba(236, 72, 153, 0.12); /* tinted bg */
--dddk-bg: #ffffff; /* subtitle bar, palette, Surface bg */
--dddk-bg-elevated: #ffffff; /* card bg */
--dddk-bg-overlay: rgba(0, 0, 0, 0.45); /* modal backdrop */
--dddk-text: #18181b;
--dddk-text-muted: #71717a;
--dddk-text-on-accent: #ffffff;
--dddk-border: rgba(0, 0, 0, 0.08);
--dddk-border-focus: var(--dddk-accent);
--dddk-success: #00aa66;
--dddk-warning: #ff9900;
--dddk-error: #cc3333;
--dddk-info: #3388dd;
}
Spacing & sizing
:root {
--dddk-radius: 10px;
--dddk-radius-sm: 6px;
--dddk-radius-lg: 16px;
--dddk-spacing-xs: 4px;
--dddk-spacing-sm: 8px;
--dddk-spacing-md: 12px;
--dddk-spacing-lg: 16px;
--dddk-spacing-xl: 24px;
--dddk-bar-max-width: 720px;
--dddk-palette-width: 600px;
--dddk-palette-max-height: 480px;
--dddk-modal-max-width: 560px;
--dddk-dock-width: 360px;
}
Typography
:root {
--dddk-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--dddk-font-mono: ui-monospace, 'SF Mono', Menlo, monospace;
--dddk-font-size-sm: 12px;
--dddk-font-size-md: 14px;
--dddk-font-size-lg: 16px;
--dddk-line-height: 1.55;
--dddk-font-weight-medium: 500;
}
Shadow & effects
:root {
--dddk-shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06);
--dddk-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08);
--dddk-shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.12);
--dddk-blur: 16px;
}
z-index
:root {
--dddk-z-bar: 9700;
--dddk-z-palette: 9600;
--dddk-z-pointer: 9500;
--dddk-z-cursor: 9800;
}
Animation
:root {
--dddk-transition-fast: 80ms ease-out;
--dddk-transition-md: 200ms ease-out;
--dddk-transition-slow: 400ms ease-out;
}
Dark mode
[data-theme="dark"] {
--dddk-bg: #09090b;
--dddk-bg-elevated: #18181b;
--dddk-text: #fafafa;
--dddk-text-muted: #a1a1aa;
--dddk-border: rgba(255, 255, 255, 0.1);
--dddk-shadow-md: 0 4px 16px rgba(0, 0, 0, 0.5);
}
Or use prefers-color-scheme:
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { /* same body */ }
}
Inheriting from host design tokens
If the host already has its own design tokens:
:root {
--dddk-accent: var(--brand-primary, #ec4899);
--dddk-bg: var(--surface-1, #ffffff);
--dddk-text: var(--text-primary, #18181b);
--dddk-radius: var(--card-radius, 10px);
--dddk-font: var(--font-sans, system-ui);
}
Full custom example — brutalist
A customer wants a brutalist look (square corners, black borders, neon yellow):
:root {
--dddk-accent: #ffeb3b;
--dddk-bg: #ffffff;
--dddk-text: #000000;
--dddk-border: #000000;
--dddk-radius: 0;
--dddk-radius-sm: 0;
--dddk-radius-lg: 0;
--dddk-shadow-md: 4px 4px 0 #000;
--dddk-font: 'Space Grotesk', sans-serif;
}
[data-dddk-ui="bar"],
[data-dddk-ui="palette"] {
border: 2px solid var(--dddk-border);
}
DOM scoping
Everything dotdotduck renders carries a [data-dddk-ui] attribute:
<div data-dddk-ui="bar"> <!-- subtitle bar -->
<div data-dddk-ui="palette"> <!-- palette container -->
<div data-dddk-ui="surface"> <!-- Surface (modal / inline / dock) -->
<div data-dddk-ui="spotter-ring"> <!-- Spotter ring -->
To target one specifically:
[data-dddk-ui="bar"] {
/* only restyle the subtitle bar */
}
Build your own theme — cookbook
Three worked examples. In each one we override the same set of variables in roughly the same order: accent → surfaces → text → border → radius → shadow → typography. That order is deliberate — accent is the most visible, surfaces second, and so on. If you tune top-down, the result reads as a coherent theme; if you skip around, you end up with a frankenstein.
Where the CSS rule lives
dddk doesn't load a "theme system" — there's no theme registry, no setTheme(). You write a CSS rule, and any element under it picks up the variables. Two common patterns:
/* Pattern A: site-wide. Put this in your global stylesheet, loaded
* after @perhapxin/dddk/styles.css so it wins on specificity tie. */
:root {
--dddk-accent: #8B7355;
/* ... */
}
/* Pattern B: scoped to one section. Put a class on a wrapper element. */
.docs-section {
--dddk-accent: #8B7355;
/* ... */
}
Pattern A is the right default. Pattern B is for cases like "the marketing site uses our brand colour, but the embedded admin tool uses a calmer one."
Example 1: sepia (warm, paper-like)
Sepia palettes read as warm and low-contrast. The win is in the surface tint, not the accent.
:root {
/* accent: a muted brown, not a bright orange */
--dddk-accent: #8B7355;
--dddk-accent-hover: #7A6347;
--dddk-accent-soft: rgba(139, 115, 85, 0.12);
/* surfaces: subtle warm tint — pure #fff would break the warm feel */
--dddk-bg: #f5ecd9;
--dddk-bg-elevated: #faf2e0;
--dddk-bg-overlay: rgba(60, 40, 20, 0.4);
/* text: dark brown, not pure black; warmth carries through */
--dddk-text: #3b2c1a;
--dddk-text-muted: #7a6750;
--dddk-text-on-accent: #faf2e0;
/* border: same hue, low alpha */
--dddk-border: rgba(80, 60, 30, 0.15);
/* radius: soft, organic */
--dddk-radius: 14px;
--dddk-radius-sm: 8px;
/* shadow: warm-tinted instead of neutral black */
--dddk-shadow-md: 0 4px 16px rgba(80, 50, 20, 0.12);
/* typography: a serif feels right for paper themes */
--dddk-font: 'Iowan Old Style', Georgia, serif;
}
The trick is avoiding pure white anywhere. The moment one surface stays #ffffff the whole theme breaks. Hit --dddk-bg, --dddk-bg-elevated, AND --dddk-bar-bg (the subtitle bar has its own — see below).
Example 2: high-contrast accessibility
For users who need maximum contrast (low vision, harsh ambient light, accessibility compliance). The win is in the borders and the shadow — not the accent.
:root {
/* accent: deeply saturated, contrasts hard against bg */
--dddk-accent: #0033cc;
--dddk-accent-hover: #002299;
--dddk-accent-soft: rgba(0, 51, 204, 0.18);
/* surfaces: pure white + pure black overlay */
--dddk-bg: #ffffff;
--dddk-bg-elevated: #ffffff;
--dddk-bg-overlay: rgba(0, 0, 0, 0.75);
/* text: pure black, no muted gray */
--dddk-text: #000000;
--dddk-text-muted: #333333; /* not lighter than #333 */
--dddk-text-on-accent: #ffffff;
/* border: 1.5–2px solid black on every chrome element */
--dddk-border: #000000;
/* radius: small or zero — busier outlines, less rounded */
--dddk-radius: 4px;
--dddk-radius-sm: 2px;
/* shadow: replaced with hard offset to mimic embossed UI */
--dddk-shadow-sm: 2px 2px 0 #000;
--dddk-shadow-md: 3px 3px 0 #000;
--dddk-shadow-lg: 4px 4px 0 #000;
}
[data-dddk-ui="bar"],
[data-dddk-ui="palette"],
[data-dddk-ui="surface"] {
border: 2px solid var(--dddk-border);
}
The explicit border: 2px solid rule is required — the default token uses rgba(0,0,0,0.08) which is intentionally subtle. Setting --dddk-border: #000 alone doesn't widen the border; you also have to bump the border-width on the chrome elements you care about.
Example 3: brand-coloured (e.g. a Notion-ish theme)
Match a host's existing design system. Most hosts already have a --brand-* token set; just bind dddk's tokens to those.
:root {
/* bind dddk to your existing tokens — fallback for safety */
--dddk-accent: var(--brand-primary, #2e2e2e);
--dddk-accent-hover: var(--brand-primary-hover, #1a1a1a);
--dddk-bg: var(--surface-1, #ffffff);
--dddk-bg-elevated: var(--surface-2, #f7f6f3);
--dddk-text: var(--text-primary, #37352f);
--dddk-text-muted: var(--text-secondary, #787774);
--dddk-border: var(--divider-default, rgba(55, 53, 47, 0.09));
--dddk-radius: var(--radius-card, 6px);
--dddk-font: var(--font-sans, 'Inter', system-ui);
}
The var(brand-x, fallback) pattern is load-bearing: if a route loads before the host CSS does (rare, but happens with code-split apps), dddk still renders with the fallback rather than unset.
Things to override that aren't in the base list
The subtitle bar has its own variables (because of past contrast bugs — see dddk/src/styles/tokens.css for the inline rationale):
:root {
--dddk-bar-bg: rgba(255, 255, 255, 0.98);
--dddk-bar-text: var(--dddk-text);
--dddk-bar-border: rgba(0, 0, 0, 0.12);
--dddk-bar-shadow: 0 12px 36px rgba(0, 0, 0, 0.18),
0 2px 8px rgba(0, 0, 0, 0.08);
}
Don't assume --dddk-bg controls the bar. It doesn't. The bar reads --dddk-bar-bg so it can stay lifted against any host bg.
Palette rows also expose their own tokens (--dddk-palette-row-bg-hover, --dddk-palette-row-bg-active, --dddk-palette-match-bg, …). See the full list in dddk/src/styles/tokens.css.
Testing checklist
After theming, verify:
- Light + dark switch correctly.
- Subtitle bar contrasts against various page backgrounds (try a light page, a hero image, a dark dashboard).
- Palette is usable at mobile / tablet / desktop widths.
- Surface modal backdrop doesn't block host sticky elements unexpectedly.
- z-index doesn't fight host modals (bump
--dddk-z-*if so). - Spotter ring is visible on every background colour your app uses.
- Font swap doesn't cause layout shift (preload the font).
Placement-specific overrides
A Surface in dock placement may want different styling:
[data-dddk-ui="surface"][data-dddk-placement="dock"] {
--dddk-radius: var(--dddk-radius-sm);
width: var(--dddk-dock-width);
height: 100vh;
right: 0;
}
Responsive / mobile
Default behaviour (no extra CSS needed)
| Element | Default scaling |
|---|---|
| Command palette | width: var(--dddk-palette-width, 600px); max-width: 90vw — shrinks on narrow screens |
| Subtitle bar | max-width: 720px; width: calc(100vw - 32px) |
| Surface modal | Whatever your placement renderer specifies; host owns this |
| Dwell frame | Pure outline, takes the element's own size — no media query needed |
| Inline AI menu | min-width: 220px; max-width: 280px |
All these sizes are tokens (--dddk-palette-width, --dddk-bar-max-width, --dddk-dock-width) — host overrides per breakpoint via @media.
Recommended breakpoint
dddk doesn't enforce a breakpoint, but this is a good baseline:
@media (max-width: 640px) {
:root {
--dddk-palette-width: 100vw;
--dddk-palette-max-height: 70vh;
--dddk-bar-bottom: 12px;
--dddk-bar-padding: 10px 12px;
}
/* Palette slides up from bottom, not centered */
[data-dddk-ui="palette-backdrop"] {
padding-top: 0;
align-items: flex-end;
}
[data-dddk-ui="palette"] {
border-radius: 16px 16px 0 0;
}
}
i18n strings
Not strictly theming, but the same idea — injected via config:
new DotDotDuck({
locale: 'zh-TW',
i18n: {
'zh-TW': {
'voice.listening': '聽取中 — 鬆開結束',
'voice.processing': '處理中...',
'palette.placeholder': '搜尋指令或頁面內容',
},
},
});
Built-in: en and zh-TW. Other locales: host supplies.
Font loading
dotdotduck doesn't bundle fonts. Host loads them:
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root { --dddk-font: 'Inter', system-ui, sans-serif; }
</style>
Shadow DOM and why we don't use it
You may have seen other widget SDKs default to Shadow DOM for style isolation. dddk does not — every DOM node it renders lives in the same tree as your app's DOM, and every style is regular global CSS reading variables. There's intentionally no isolation: 'shadow' config option.
Why not Shadow DOM
- Hosts already want to theme dddk. Shadow DOM exists to prevent host CSS from reaching the widget. But every dddk integration we've seen wants the opposite — match the host's brand colour, font, radius. Shadow DOM would force every theme override to be passed through a JS API or
::part()selectors, neither of which is as flexible as global CSS variables. - CSS variables already cross the boundary. Even if dddk were in a shadow root, CSS custom properties inherit through it. So you'd still need the variable token surface. Shadow DOM would add a layer of indirection without solving the actual problem (host CSS leaking into dddk).
- Inspector / devtools friction. Inspecting a shadow root requires opening it explicitly. For a piece of UI users see all the time, that's a daily annoyance for the developers shipping it.
- Keyboard event subtleties. Some keyboard events behave differently inside a shadow root (focus boundaries, retargeting). dddk relies heavily on keyboard input — palette, gestures, voice trigger — and we'd rather not court those edge cases.
- Tree-shake + bundle size. Shadow DOM mode would need its own style injection path. Easier and smaller to ship one CSS file and let the host's bundler dedupe.
What dddk does instead
- Scoped selectors. Every dddk root has
[data-dddk-ui="..."]. Style rules indddk/src/styles/*.cssalways start with[data-dddk-ui="bar"],[data-dddk-ui="palette"], etc. — so host CSS that targetsdiv,button, or class names doesn't accidentally hit dddk. - Variable-driven values. Every colour / radius / font reads from a
--dddk-*token. The token is the public theming API; the implementation CSS is not. - High z-index defaults. dddk chrome (
--dddk-z-bar,--dddk-z-palette, …) is in the 9000–9800 band so it sits above almost any host UI. If the host has a modal that needs to be on top of dddk, override the tokens.
What to watch for (since we don't have Shadow DOM)
- Specificity wars. If a host CSS rule like
.app button { background: red }is loaded after@perhapxin/dddk/styles.css, it can override a dddk button's background. Fix: load@perhapxin/dddk/styles.cssafter your global host styles (or use[data-dddk-ui]scoping in your host CSS). - Accent inheritance.
--dddk-accentflows through every dddk surface — palette focus row, button bg, Dwell ring, Spotter ring. Overriding it once at:rootis usually what you want. If a host overrides at a deeper level (.dashboard { --dddk-accent: blue }) only dddk UI rendered inside.dashboardwill see it; UI portalled to<body>(palette, modal Surface) will not. For a single accent across the whole app, set it on:rootorhtml. - Reset stylesheets. If a host applies an aggressive CSS reset (
* { all: unset }or similar), dddk's UI breaks because it relies on default rendering for elements like<button>. Reset rules should be scoped to host content, not global.
If your environment genuinely requires hard isolation (e.g. you're embedding dddk into an untrusted host page like a third-party extension), file an issue — Shadow DOM mode is something we'd revisit if the use case appears, but as of v0.1.0 it's not on the roadmap.