| name | description |
|---|---|
lit-components |
Build web components with Lit 3.x. Covers component definition, reactive properties, templates, styles, lifecycle, events, Shadow DOM, slots, controllers, and SSR. Use when writing Lit components or reviewing Lit code. Also use when the user mentions LitElement, @customElement, @property, @state, html tagged template, css tagged template, shadow DOM, web components with Lit, or reactive properties. |
Build fast, lightweight web components using Lit 3.x. Lit adds reactivity and declarative templates on top of the Web Components standards, weighing ~5KB compressed.
Reference: https://lit.dev/docs/
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('my-greeting')
export class MyGreeting extends LitElement {
static styles = css`:host { display: block; }`;
@property() name = 'World';
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}import { LitElement, html } from 'lit';
export class MyGreeting extends LitElement {
static properties = { name: { type: String } };
constructor() {
super();
this.name = 'World'; // Initialize in constructor, NOT as class field
}
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
customElements.define('my-greeting', MyGreeting);- Creating a new Lit web component
- Adding reactive properties or internal state
- Writing templates with
htmltagged literals - Styling components with
csstagged literals and Shadow DOM - Handling events or dispatching custom events
- Working with slots and composition
- Implementing lifecycle methods
- Creating reactive controllers
- Making components SSR-compatible
- Import from
litandlit/decorators.js - Extend
LitElement - Add
@customElement('tag-name')decorator (orcustomElements.define()) - Add
static styleswithcsstagged template - Add reactive properties with
@property()or@state() - Implement
render()returninghtmltagged template - Add TypeScript HTMLElementTagNameMap declaration for type safety
- Choose decorator:
@property()— public API, syncs with attribute@state()— internal only, no attribute
- Set options:
type,attribute,reflect,hasChanged - Initialize with default value
- Use in
render()template
- Create
new CustomEvent('event-name', { detail, bubbles: true, composed: true }) - Await
this.updateCompleteif DOM must reflect state first - Call
this.dispatchEvent(event)
- Add
<slot></slot>(default) or<slot name="name"></slot>(named) in template - Style slotted content with
::slotted(selector) - Query with
@queryAssignedElements()orslot.assignedElements()
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('my-element')
export class MyElement extends LitElement {
static styles = css`
:host { display: block; }
`;
@property({ type: String })
name = 'World';
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
// TypeScript: Extend HTMLElementTagNameMap for type-safe DOM APIs
declare global {
interface HTMLElementTagNameMap {
'my-element': MyElement;
}
}{
"compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}Critical: In JavaScript (without decorators), avoid class field initializers for reactive properties—they prevent accessors from functioning. Initialize in the constructor instead.
| Decorator | Attribute | External Access | Use Case |
|---|---|---|---|
@property() |
Yes (observed) | Public API | Props passed by consumers |
@state() |
No | Internal only | Private component state |
@property({
type: String, // Conversion type: String, Number, Boolean, Array, Object
attribute: 'my-attr', // Custom attribute name (false to disable)
reflect: true, // Sync property changes back to attribute
hasChanged: (n, o) => n !== o, // Custom change detection
converter: { // Custom attribute↔property conversion
fromAttribute: (value, type) => { /* string → property */ },
toAttribute: (value, type) => { /* property → string */ }
}
})
myProp = 'default';| Type | Attribute → Property | Property → Attribute |
|---|---|---|
| String | Direct assignment | Direct assignment |
| Number | Number(value) |
String(value) |
| Boolean | Presence = true, absence = false | Add/remove attribute |
| Object/Array | JSON.parse() |
JSON.stringify() |
Boolean properties must default to false to be configurable from markup. A property defaulting to true cannot be set to false via attributes.
// ✓ Correct: defaults to false
@property({ type: Boolean, reflect: true })
disabled = false;
// ✗ Wrong: can't set false from markup
@property({ type: Boolean })
enabled = true;Mutating objects/arrays doesn't trigger updates—references must change:
// ✗ Won't trigger update
this.items.push(newItem);
// ✓ Create new reference
this.items = [...this.items, newItem];
// ✓ Filter creates new array
this.items = this.items.filter((_, i) => i !== indexToRemove);render() {
return html`
<h1>${this.title}</h1>
<p class=${this.highlight ? 'highlight' : ''}>Content</p>
<input .value=${this.inputValue}>
<button @click=${this._handleClick}>Click</button>
`;
}| Prefix | Binding Type | Example |
|---|---|---|
| (none) | Text/attribute | <p>${text}</p>, class="${cls}" |
. |
Property | <input .value=${val}> |
? |
Boolean attribute | <button ?disabled=${dis}> |
@ |
Event listener | <button @click=${handler}> |
// Ternary (preferred for simple cases)
${this.loggedIn ? html`<p>Welcome</p>` : html`<p>Please log in</p>`}
// nothing sentinel (removes node entirely)
${this.showLabel ? html`<label>...</label>` : nothing}
// Conditional attributes
<button aria-label="${this.ariaLabel || nothing}">// map() - simple, efficient for most cases
${this.items.map(item => html`<li>${item.name}</li>`)}
// repeat() - use when reordering large lists or preserving DOM state
import { repeat } from 'lit/directives/repeat.js';
${repeat(
this.items,
item => item.id, // Key function
item => html`<li>${item.name}</li>`
)}Use repeat when:
- Reordering large lists (moving DOM nodes is cheaper than updating values)
- Items have uncontrolled DOM state (focus, selection, scroll position)
The render() method can return:
- Primitives (string, number, boolean)
TemplateResultfromhtml- DOM Nodes
nothing(renders nothing)noChange(skips update)- Arrays/iterables of the above
static styles = css`
:host {
display: block;
--my-color: blue;
}
:host([disabled]) {
opacity: 0.5;
}
.container {
color: var(--my-color);
background: var(--my-bg, white);
}
`;static styles = [
sharedStyles,
css`/* component-specific styles */`
];| Selector | Target |
|---|---|
:host |
The component element itself |
:host(selector) |
Host matching selector (e.g., :host([disabled])) |
::slotted(*) |
Direct children in slots |
::slotted(p) |
Specific slotted elements |
Component definition:
static styles = css`
:host {
background: var(--my-element-bg, white);
color: var(--my-element-color, black);
}
`;Consumer customization:
my-element {
--my-element-bg: navy;
--my-element-color: white;
}import { classMap } from 'lit/directives/class-map.js';
import { styleMap } from 'lit/directives/style-map.js';
render() {
const classes = { active: this.active, disabled: this.disabled };
const styles = { color: this.color, fontSize: `${this.size}px` };
return html`
<div class=${classMap(classes)} style=${styleMap(styles)}>
Content
</div>
`;
}Property change → requestUpdate() → shouldUpdate() → willUpdate()
→ update() → render() → firstUpdated()/updated() → updateComplete ✓
connectedCallback() {
super.connectedCallback();
// Setup: add external event listeners, start observers
window.addEventListener('resize', this._onResize);
}
disconnectedCallback() {
super.disconnectedCallback();
// Cleanup: remove external listeners, disconnect observers
window.removeEventListener('resize', this._onResize);
}
willUpdate(changedProps: PropertyValues) {
// Compute derived values before render
// Changes here don't trigger additional updates
if (changedProps.has('firstName') || changedProps.has('lastName')) {
this._fullName = `${this.firstName} ${this.lastName}`;
}
}
firstUpdated(changedProps: PropertyValues) {
// One-time DOM setup after first render
this.shadowRoot.querySelector('input')?.focus();
}
updated(changedProps: PropertyValues) {
// Runs after every render
// Property changes here schedule new updates
if (changedProps.has('open')) {
this._animatePanel();
}
}async someMethod() {
this.prop = 'new value';
await this.updateComplete;
// DOM is now updated
}render() {
return html`
<button @click=${this._onClick}>Click</button>
<input @input=${this._onInput}>
`;
}
private _onClick(e: Event) {
// 'this' is automatically bound to the component
}import { eventOptions } from 'lit/decorators.js';
@eventOptions({ passive: true, capture: true })
private _onScroll(e: Event) { }private _dispatchChange() {
const event = new CustomEvent('my-change', {
detail: { value: this.value },
bubbles: true, // Propagates up DOM tree
composed: true // Crosses shadow DOM boundaries
});
this.dispatchEvent(event);
}
// Dispatch after DOM updates
async _handleAction() {
this.value = newValue;
await this.updateComplete;
this._dispatchChange();
}| Option | Effect |
|---|---|
bubbles: true |
Event propagates to ancestors |
composed: true |
Event escapes shadow DOM boundary |
| Both | Event visible to all ancestors including across shadow roots |
Retargeting: Events dispatched from shadow DOM appear to come from the host element when observed outside the shadow root. Use event.composedPath() to get the actual origin.
render() {
return html`
<div class="wrapper">
<slot></slot> <!-- Renders children -->
</div>
`;
}render() {
return html`
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
`;
}Usage:
<my-element>
<h1 slot="header">Title</h1>
<p>Default slot content</p>
<p slot="footer">Footer content</p>
</my-element><slot>Default content if nothing slotted</slot>Note: Fallback won't render if any nodes are assigned—including whitespace text nodes.
@queryAssignedElements({ slot: 'items', selector: '.item' })
_items!: Array<HTMLElement>;
// Or manually
get _slottedChildren() {
const slot = this.shadowRoot?.querySelector('slot');
return slot?.assignedElements({ flatten: true }) ?? [];
}@query('#myButton')
_button!: HTMLButtonElement;
@queryAll('.item')
_items!: NodeListOf<HTMLElement>;
@queryAsync('#lazyElement')
_lazyEl!: Promise<HTMLElement>;Reusable state and behavior that hooks into the component lifecycle without modifying the prototype.
import { ReactiveController, ReactiveControllerHost } from 'lit';
export class MouseController implements ReactiveController {
host: ReactiveControllerHost;
pos = { x: 0, y: 0 };
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
private _onMouseMove = (e: MouseEvent) => {
this.pos = { x: e.clientX, y: e.clientY };
this.host.requestUpdate();
};
hostConnected() {
window.addEventListener('mousemove', this._onMouseMove);
}
hostDisconnected() {
window.removeEventListener('mousemove', this._onMouseMove);
}
}
// Usage in component
@customElement('my-element')
class MyElement extends LitElement {
private mouse = new MouseController(this);
render() {
return html`<p>Mouse: ${this.mouse.pos.x}, ${this.mouse.pos.y}</p>`;
}
}| Method | When Called |
|---|---|
hostConnected() |
Host's connectedCallback |
hostDisconnected() |
Host's disconnectedCallback |
hostUpdate() |
Before host update/render |
hostUpdated() |
After host update completes |
Lit supports server-side rendering via @lit-labs/ssr. For SSR-compatible components:
- Use shadow DOM (required for Lit SSR)
- Avoid async work in render — keep
render()synchronous - Use
willUpdate()for computed values — it runs on both server and client - Defer DOM-dependent logic to
firstUpdated()— only runs in browser
- Use
static stylesfor all component styles (evaluated once, shared across instances) - Initialize reactive properties to sensible defaults
- Keep
render()pure—no side effects or state mutations - Use
willUpdate()for derived/computed values - Dispatch events after
await this.updateComplete - Use
nothingto conditionally remove nodes/attributes - Create new object/array references to trigger updates
- Call
superin lifecycle methods
- Don't mutate reactive properties in
render() - Don't use class field initializers for reactive properties in plain JS
- Don't reflect large Objects/Arrays to attributes (performance/memory cost)
- Don't add event listeners inside
render()unless using@eventsyntax - Don't skip
super.connectedCallback()/super.disconnectedCallback() - Don't access DOM in constructor—use
firstUpdated()instead - Don't default boolean properties to
true
@property({ type: String })
value = '';
private _onInput(e: InputEvent) {
this.value = (e.target as HTMLInputElement).value;
this.dispatchEvent(new CustomEvent('input', { bubbles: true, composed: true }));
}
render() {
return html`<input .value=${this.value} @input=${this._onInput}>`;
}static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true
};@queryAsync('#heavy-component')
_heavyComponent!: Promise<HeavyComponent>;
async activateHeavy() {
const component = await this._heavyComponent;
component.activate();
}