Skip to content

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.

All three, liverunning the real bundleEdit in playground
defaultwarnok
theming.js
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']

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 it

Global 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, immediately
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

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 referencecss, vars, adoptGlobal