Theming for consumers
This is the part most web components get wrong, and the reason many teams reject them outright. A shadow root is opaque: it protects the author and blocks everyone else. If a consumer cannot make your button match their design system, they will not use your button.
The platform provides three ways through the boundary. Alacris leans on all three rather than inventing a fourth.
import { define, html, css, vars, adoptGlobal } from '@alacris/core';
// The component declares what it can be themed by. Each token compiles to// var(--chip-bg, <default>), so a consumer overrides it with plain CSS.const chip = vars('chip', { bg: '#eceef3', fg: '#16161a', dot: '#8a8a99', radius: '999px',});
define('demo-chip', { props: { tone: '' }, styles: css` :host { display: inline-flex; align-items: center; gap: .5rem; padding: .3rem .8rem; margin: 0 .35rem .35rem 0; background: ${chip.bg}; color: ${chip.fg}; border-radius: ${chip.radius}; font: inherit; font-size: .9rem; } .dot { width: 8px; height: 8px; border-radius: 50%; background: ${chip.dot} } `, setup: (p) => html` <span class="dot" part="dot" style=${() => ({ '--chip-dot': p.tone() === 'warn' ? '#e8a33d' : p.tone() === 'ok' ? '#3da35d' : '', })}></span> <slot></slot>`,});
// A consumer theming a component library it does not own: one call, every// instance, including any created later.const DARK = css` :host { --chip-bg: #22222a; --chip-fg: #f2f2f5; --chip-radius: 6px }`;
define('demo-theme-switch', { styles: ` :host { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .75rem } button { font: inherit; padding: .35rem .7rem; border-radius: 6px; cursor: pointer; border: 1px solid currentColor; background: transparent; color: inherit } `, setup() { let undoTheme = null; let partRule = null;
const toggleTheme = () => { if (undoTheme) { undoTheme(); undoTheme = null; } else undoTheme = adoptGlobal(DARK); }; const togglePart = () => { if (partRule) { partRule.remove(); partRule = null; return; } partRule = document.createElement('style'); partRule.textContent = 'demo-chip::part(dot) { width: 16px; height: 16px }'; document.head.append(partRule); };
return html` <button @click=${toggleTheme}>toggle global theme</button> <button @click=${togglePart}>toggle ::part rule</button>`; },});1. Custom properties — the theming contract
Section titled “1. Custom properties — the theming contract”Custom properties inherit straight through a shadow boundary, which makes them
the natural channel. vars declares the ones your component honours, with
defaults baked into each declaration:
import { define, html, css, vars } from '@alacris/core';
const t = vars('btn', { bg: '#111', fg: '#fff', radius: '8px' });// t.bg === 'var(--btn-bg, #111)'
define('x-btn', { styles: css` :host { background: ${t.bg}; color: ${t.fg}; border-radius: ${t.radius} } `, setup: () => html`<button part="control"><slot></slot></button>`,});A consumer overrides it from anywhere above the element, with plain CSS and no knowledge of Alacris:
x-btn { --btn-bg: rebeccapurple }.dark x-btn { --btn-bg: #eee; --btn-fg: #111 }[data-density=sm] { --btn-radius: 4px }camelCase keys become kebab-case properties, and t.names is the generated
list — the component’s documented theming contract, and something a docs
generator can read:
t.names; // ['--btn-bg', '--btn-fg', '--btn-radius']2. ::part — reach specific internals
Section titled “2. ::part — reach specific internals”Mark the elements you are willing to expose. Consumers then style them directly, with the full power of CSS:
html`<button part="control"><slot></slot></button>`x-btn::part(control) { padding: 1rem; letter-spacing: .02em }x-btn::part(control):hover { transform: translateY(-1px) }Outer ::part rules beat the component’s own rules. That is the platform’s
cascade order, and it means a consumer always wins without specificity games.
A part is public API. Renaming one is a breaking change, so expose
deliberately.
For a part inside a nested component, forward it with the platform’s
exportparts:
html`<x-icon exportparts="glyph: btn-glyph"></x-icon>`x-btn::part(btn-glyph) { opacity: .6 }3. adoptGlobal — restyle a library you do not control
Section titled “3. adoptGlobal — restyle a library you do not control”Sometimes you need to theme a component set whose source you cannot edit.
adoptGlobal pushes a stylesheet into every Alacris component — those
already on the page, and every one created afterwards:
import { adoptGlobal, css } from '@alacris/core';
const remove = adoptGlobal(css` :host { font-family: Inter, system-ui } button { border-radius: 999px }`);
remove(); // undo itGlobal styles are applied after each component’s own, so they win ties
without !important. Combined with Sheet.replace(), a whole-page theme switch
is a single write:
const theme = css`:host { --btn-bg: #111 }`;adoptGlobal(theme);
theme.replace(':host { --btn-bg: #eee }'); // every component, immediatelyChoosing between them
Section titled “Choosing between them”| You want | Use |
|---|---|
| A value the component expects to vary | custom properties |
| To restyle a specific internal element | ::part |
| To theme a whole component library at once | adoptGlobal |
| To change layout inside a component you do not own | reconsider — that is a fork, and the author should expose a part |
Documenting your contract
Section titled “Documenting your contract”A component’s public styling surface is its props, its parts, its slots and its custom properties. Listing them is what makes a component adoptable:
### <x-btn>
| Custom property | Default | Effect || --------------- | ------- | --------------- || `--btn-bg` | `#111` | background || `--btn-fg` | `#fff` | text colour || `--btn-radius` | `8px` | corner radius |
| Part | What it is || --------- | ---------------------- || `control` | the inner `<button>` |
| Slot | What goes in it || --------- | ---------------------- || (default) | the label |- Styling — the author’s side
- Alacris UI — a design system that uses this
contract end to end, plus a generated palette and
applyTheme - API reference —
css,vars,adoptGlobal