@design.estate/dees-element
A powerful custom element base class that extends Lit's LitElement with integrated theming, responsive CSS utilities, RxJS-powered directives, and DOM tooling — so you can build web components that look great and stay reactive out of the box.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
npm install @design.estate/dees-element
# or
pnpm install @design.estate/dees-element
This package ships as ESM and is written in TypeScript. Make sure your project targets ES2022+ with a modern module resolution strategy (e.g. NodeNext).
Usage
Everything you need is exported from the main entry point:
import {
DeesElement,
customElement,
property,
state,
html,
css,
cssManager,
directives,
} from '@design.estate/dees-element';
🧱 Creating a Custom Element
Extend DeesElement and apply the @customElement decorator:
import { DeesElement, customElement, html, css, cssManager } from '@design.estate/dees-element';
@customElement('my-button')
class MyButton extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`
.btn {
padding: 8px 16px;
border-radius: 4px;
background: ${cssManager.bdTheme('#0060df', '#3a8fff')};
color: ${cssManager.bdTheme('#fff', '#fff')};
border: none;
cursor: pointer;
}
`,
];
render() {
return html`<button class="btn"><slot></slot></button>`;
}
}
That single bdTheme() call generates a CSS variable that automatically flips between the bright and dark values when the user's theme changes — no manual toggling needed.
🧪 Demo Wrapper Utilities
Component demos can import the demotools subpath to register <dees-demowrapper>. The wrapper renders its slotted demo content and then runs runAfterRender, which is useful for demos that need to set up state, query rendered light-DOM children, or simulate user interaction after first render.
import { html } from '@design.estate/dees-element';
import { DeesDemoWrapper } from '@design.estate/dees-element/demotools';
export const demo = () => html`
<dees-demowrapper
.runAfterRender=${(wrapper: DeesDemoWrapper) => {
const button = wrapper.querySelector('button');
button?.dispatchEvent(new Event('click', { bubbles: true }));
}}
>
<button>Demo action</button>
</dees-demowrapper>
`;
🎨 Theme Management with cssManager
The singleton cssManager is the central hub for theming and responsive layout:
| Method | Purpose |
|---|---|
cssManager.defaultStyles |
Base styles for consistent element rendering |
cssManager.bdTheme(bright, dark) |
Returns a CSSResult that auto-switches between bright/dark values |
cssManager.cssForDesktop(css, this?) |
Breakpoint for desktop; pass this for component-scoped |
cssManager.cssForNotebook(css, this?) |
Breakpoint for notebook; pass this for component-scoped |
cssManager.cssForTablet(css, this?) |
Breakpoint for tablet; pass this for component-scoped |
cssManager.cssForPhablet(css, this?) |
Breakpoint for phablet; pass this for component-scoped |
cssManager.cssForPhone(css, this?) |
Breakpoint for phone; pass this for component-scoped |
cssManager.cssForConstraint({ maxWidth, minWidth }) |
Custom viewport-level constraint (curried) |
cssManager.cssGridColumns(cols, gap) |
Generates CSS grid column widths |
Example — responsive + themed styles:
@customElement('my-card')
class MyCard extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
padding: 16px;
background: ${cssManager.bdTheme('#ffffff', '#1e1e1e')};
color: ${cssManager.bdTheme('#111', '#eee')};
border-radius: 8px;
}
`,
cssManager.cssForPhone(css`
:host { padding: 8px; }
`),
];
render() {
return html`<slot></slot>`;
}
}
📦 Container-Responsive Components
For components that need to respond to their own width (not the viewport), use the @containerResponsive() decorator and pass this as the second argument to cssManager.cssFor*:
import {
DeesElement, customElement, html, css, cssManager,
containerResponsive,
} from '@design.estate/dees-element';
@containerResponsive()
@customElement('my-stats-grid')
class MyStatsGrid extends DeesElement {
static styles = [
cssManager.defaultStyles,
css`.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }`,
// Component-level: when THIS element is narrower than tablet width
cssManager.cssForTablet(css`
.grid { grid-template-columns: repeat(2, 1fr); }
`, this),
// Viewport-level: when the browser window is phone-sized
cssManager.cssForPhone(css`
.grid { grid-template-columns: 1fr; }
`),
// Component-level with custom width constraint
this.cssForConstraint({ maxWidth: 500 })(css`
.grid { gap: 8px; }
`),
];
render() {
return html`<div class="grid"><slot></slot></div>`;
}
}
How it works:
| API | Scope | Generated CSS |
|---|---|---|
cssManager.cssForPhablet(css) |
Viewport | @media + @container wccToolsViewport |
cssManager.cssForPhablet(css, this) |
Component | @container <tag-name> only |
cssManager.cssForConstraint({maxWidth:800})(css) |
Viewport | @media + @container wccToolsViewport |
this.cssForConstraint({maxWidth:500})(css) |
Component | @container <tag-name> only |
@containerResponsive() |
Decorator | Sets container-type: inline-size + container-name on :host |
The @containerResponsive() decorator is required for component-scoped queries — it establishes the CSS containment context on :host.
⚡ Reactive Properties & State
Use the standard Lit decorators, re-exported for convenience:
import { DeesElement, customElement, property, state, html } from '@design.estate/dees-element';
@customElement('my-counter')
class MyCounter extends DeesElement {
@property({ type: String })
accessor label = 'Count';
@state()
accessor count = 0;
render() {
return html`
<button @click=${() => this.count++}>
${this.label}: ${this.count}
</button>
`;
}
}
Note: This library uses the TC39 standard decorators with the
accessorkeyword for decorated class properties.
🔄 Theme Change Callbacks
DeesElement tracks the current theme via the goBright property and exposes an optional themeChanged callback:
@customElement('theme-aware')
class ThemeAware extends DeesElement {
protected themeChanged(goBright: boolean) {
console.log(goBright ? 'Switched to bright' : 'Switched to dark');
}
render() {
return html`<p>Current theme: ${this.goBright ? 'bright' : 'dark'}</p>`;
}
}
goBright is reflected as the gobright attribute on the host element, so themes can be styled in plain CSS without JavaScript or per-value bdTheme() pairs:
public static styles = [
css`
:host {
background: #000;
}
:host([gobright]) {
background: #fff;
}
`,
];
The attribute updates automatically whenever the theme switches.
🚀 Lifecycle Helpers
DeesElement adds lifecycle utilities on top of LitElement:
@customElement('my-widget')
class MyWidget extends DeesElement {
constructor() {
super();
// Runs for each connection, after the previous connection has finished cleanup.
this.registerStartupFunction(async (signal) => {
if (signal.aborted) return;
console.log('Widget connected!');
});
// Runs when the element is disconnected — perfect for cleanup
this.registerGarbageFunction(() => {
console.log('Widget removed');
});
}
render() {
return html`<p>Hello World</p>`;
}
}
Additionally, this.elementDomReady is a promise that resolves after firstUpdated, which is handy when you need to wait for the initial render:
await this.elementDomReady;
// The element's shadow DOM is now fully rendered
Startup hooks receive an AbortSignal that is aborted synchronously on removal. Use it
to cancel work that supports cancellation. Hooks already running are allowed to settle;
garbage hooks then finish before the next connection starts. Register hooks once, usually
in the constructor. Resources created by a startup hook belong to that connection.
Lit directives disconnect and rxSubscriptions unsubscribe immediately on removal.
Subscriptions added by a finishing startup hook are drained before cleanup completes.
Every garbage hook is attempted even if another throws; the returned disconnect promise
rejects with an AggregateError, and later connections can still run. Subclasses should
call the superclass lifecycle method immediately and await its returned promise before
starting connection-specific asynchronous work.
The resolve directive ignores stale promises, retains pending work across removal, and
renders the current promise after reconnection even if it settled while detached.
🌍 Languages with localize and provideI18n
A component speaks the words of its package's catalog, a typed
@push.rocks/smarti18n catalog of ICU messages. The app
decides the language once, by providing an I18n instance; every component below re-renders when it changes.
1. The component package defines its catalog (see the smarti18n readme for messages, translations and checks):
import { defineCatalog } from '@push.rocks/smarti18n';
export const messages = defineCatalog({
namespace: '@my/components',
source: 'en',
messages: {
counter: 'Step {current, number} of {total, number}',
help: 'Read the <link>guide</link>.',
},
});
messages.addTranslation('de', () => import('./messages.de.js'));
2. A component takes its words with localize:
import { DeesElement, customElement, html, localize } from '@design.estate/dees-element';
import { messages } from './messages.js';
@customElement('my-stepper')
class MyStepper extends DeesElement {
private t = localize(this, messages);
render() {
return html`
<span>${this.t('counter', { current: 2, total: 4 })}</span>
<p>${this.t.rich('help', { link: (chunks) => html`<a href="/guide">${chunks}</a>` })}</p>
<small>${this.t.format.number(1234.5)}</small>
`;
}
}
- Keys and parameters are typed:
this.t('counter', { current: 2 })andthis.t('countr')are compile errors. this.t.formatwrites numbers, money, dates, lists and relative times in the formats the catalog's messages are written in, so it always agrees with the numbers inside messages, andthis.t.i18nis the instance the element speaks now.
3. The app provides its instance, on <html> so that elements appended to document.body (modals, menus) find it
too:
import { createI18n } from '@push.rocks/smarti18n';
import { provideI18n } from '@design.estate/dees-element';
const i18n = createI18n({ locale: 'de-DE' });
provideI18n(document.documentElement, i18n, { reflect: true }); // keeps <html lang dir> in step
await i18n.setLocale('en-GB'); // every localized element re-renders in English
How it behaves:
-
Without a provider an element speaks its catalog's source language (smarti18n's
sourceI18n). A provider that appears later is still found, so the order of setup does not matter. -
A fallback of its own:
localize(this, messages, { fallback })speaksfallbackinstead ofsourceI18nwhile no provider serves the element, and again when its provider is disposed. A component library keeps formats of its own that way until a host provides a language, for example numbers without grouping, in messages andt.formatalike:import { createSourceI18n, Formats } from '@push.rocks/smarti18n'; class UngroupedFormats extends Formats { public override intlNumberFormat(options: Intl.NumberFormatOptions): Intl.NumberFormat { return super.intlNumberFormat({ useGrouping: false, ...options }); } } const neutral = createSourceI18n({ createFormats: (settings) => new UngroupedFormats(settings) }); // in the component private t = localize(this, messages, { fallback: neutral }); -
A subtree can speak another language:
provideI18n(previewElement, documentI18n)serves the elements belowpreviewElement(a document preview, a demo side by side). On aDeesElementhost the provider lives with the host;reflectsetslanganddiron it while it is connected.provider.i18n = otherhands the subtree another instance.provider.dispose()stops a provider and restores its host'slanganddir; a provider on a plain element, such as<html>, lives until then. -
An overlay can speak its opener's language: a provider serves the elements below its host, never the host's own
localize()words.provideI18n(overlay, openerI18n, { includeHost: true })serves the host too: its own words and the content it renders speakopenerI18napart from the page, follow a locale switch orprovider.i18n = other, and return to the page's language onprovider.dispose(). It works whether the host is connected already or connects later. -
No render before the words are in.
DeesElement.scheduleUpdate()waits until the catalogs an element uses are loaded for its language, so a lazily defined catalog never shows its source words first. It waits only while words are still to load: with every word loaded, an update is synchronous, so a localized child that a parent renders is current once the parent'supdateCompleteresolves. Words that fail to load rejectupdateComplete(with the render's own error as well, in anAggregateError); the next update tries again. A subclass that overridesscheduleUpdate()must callsuper.scheduleUpdate(). -
No leaks. An element subscribes to its provider and its instance on connect and unsubscribes on disconnect.
-
Right to left.
i18n.directionisrtlfor Arabic, Hebrew, Persian and the pseudo-localear-XB; withreflect,dir="rtl"flips the page.en-XA(createI18n({ locale: 'en-XA', pseudo: true })) shows clipped or untranslated text at a glance. -
The context is
i18nContext(the Web Components context protocol, via@lit/context), keyed bySymbol.for('dees.i18n'), so every copy of this package in a page shares it.
📡 Directives
The directives namespace includes powerful template helpers, accessible via directives.*:
resolve — Render a Promise
import { html, directives } from '@design.estate/dees-element';
render() {
return html`${directives.resolve(this.fetchData())}`;
}
resolveExec — Resolve a lazy async function
render() {
return html`${directives.resolveExec(() => this.loadContent())}`;
}
subscribe — Render an RxJS Observable
import { html, directives } from '@design.estate/dees-element';
render() {
return html`<span>${directives.subscribe(this.count$)}</span>`;
}
subscribeWithTemplate — Observable + template transform
render() {
return html`
${directives.subscribeWithTemplate(
this.items$,
(items) => html`<ul>${items.map(i => html`<li>${i}</li>`)}</ul>`
)}
`;
}
Re-exported Lit directives
The directives namespace also re-exports these commonly used Lit directives:
until— render a placeholder while a promise resolvesasyncAppend— append values from an async iterablekeyed— force re-creation of a template when a key changesrepeat— efficiently render lists with identity tracking
📦 Full Export Reference
| Export | Description |
|---|---|
DeesElement |
Base class for custom elements |
cssManager |
Singleton CssManager instance |
customElement |
Class decorator to register elements |
property |
Reactive property decorator |
state |
Internal state decorator |
query, queryAll, queryAsync |
Shadow DOM query decorators |
html |
Lit html template tag |
css |
Lit css template tag |
unsafeCSS |
Create CSSResult from a string |
unsafeHTML |
Render raw HTML in templates |
render |
Lit render function |
static / unsafeStatic |
Static html template helpers |
containerResponsive |
Decorator that adds CSS containment to :host |
domtools |
DOM tooling utilities |
directives |
All directives (resolve, subscribe, etc.) |
localize, ILocalizer (type), ILocalizeOptions (type) |
A catalog's typed words for an element, in the provided language or its fallback |
provideI18n, I18nProvider, IProvideI18nOptions (type) |
Provide an I18n to a page or a subtree |
i18nContext |
The context an I18n is provided under |
rxjs (type) |
RxJS type re-export |
DeesDemoWrapper from @design.estate/dees-element/demotools |
Demo helper custom element for post-render callbacks |
In dees-element 3, the public domtools namespace exposes dees-domtools 3 and
TypedRequest 8. Consumers upgrading from dees-element 2 must use the TypedRequest
8 request envelope, including its per-attempt requestInstanceId semantics.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.