Getting started
Installation
npm install lit
// Import from lit
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
Basic component
import { LitElement, html, css } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("my-element")
export class MyElement extends LitElement {
@property() name = "World";
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
Using in HTML
<!-- After component is registered -->
<my-element name="Lit"></my-element>
<!-- Properties become attributes -->
<my-element name="Components"></my-element>
Defining components
With decorators (TypeScript)
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("simple-greeting")
export class SimpleGreeting extends LitElement {
@property() name = "Somebody";
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
Requires: "experimentalDecorators": true in tsconfig.json
Without decorators (JavaScript)
import { LitElement, html } from "lit";
export class SimpleGreeting extends LitElement {
static properties = {
name: { type: String },
};
constructor() {
super();
this.name = "Somebody";
}
render() {
return html`<p>Hello, ${this.name}!</p>`;
}
}
// Register element
customElements.define("simple-greeting", SimpleGreeting);
Templates
html tagged template
render() {
return html`
<h1>${this.title}</h1>
<p>Count: ${this.count}</p>
`;
}
Templates are reactive - re-render when properties change
Expressions
html`
<!-- Properties -->
<div>${this.message}</div>
<!-- Computed values -->
<div>${this.count * 2}</div>
<!-- Ternary operators -->
<div>${this.active ? "Active" : "Inactive"}</div>
<!-- Template literals -->
<div class="item-${this.id}"></div>
`;
Attribute bindings
html`
<!-- Boolean attribute -->
<button ?disabled=${!this.active}>Click</button>
<!-- Property (not attribute) -->
<input .value=${this.text} />
<!-- Event listener -->
<button @click=${this.handleClick}>Click</button>
`;
| Prefix | Type | Example |
|---|---|---|
? |
Boolean | ?disabled=${expr} |
. |
Property | .value=${expr} |
@ |
Event | @click=${handler} |
| (none) | String | class=${expr} |
Conditional rendering
render() {
return html`
${this.user ? html`
<p>Welcome ${this.user.name}</p>
` : html`
<p>Please log in</p>
`}
`;
}
Rendering lists
import { html } from 'lit';
render() {
return html`
<ul>
${this.items.map(item => html`
<li>${item.name}</li>
`)}
</ul>
`;
}
⚠️ Note: Use repeat() directive for keyed lists (better performance)
Reactive properties
@property decorator
import { property } from 'lit/decorators.js';
@property({ type: String })
name = 'World';
@property({ type: Number })
count = 0;
@property({ type: Boolean })
disabled = false;
@property({ type: Array })
items = [];
@property({ type: Object })
user = {};
Triggers re-render on change
Property options
@property({
type: String, // Type for attribute conversion
attribute: 'user-name', // Custom attribute name
reflect: true, // Reflect property to attribute
converter: myConverter, // Custom converter
hasChanged: myComparison // Custom change detection
})
userName = '';
@property({ attribute: false }) // No attribute
internalData = {};
@state decorator
import { state } from 'lit/decorators.js';
// Internal reactive state (no attribute)
@state()
private _active = false;
@state()
private _count = 0;
Use @state for internal state that triggers re-render but doesn't need attribute
Attribute conversion
// String (default)
@property()
text = 'hello';
// Number
@property({ type: Number })
count = 0;
// Boolean
@property({ type: Boolean })
disabled = false;
// Array (JSON)
@property({ type: Array })
items = [];
// Object (JSON)
@property({ type: Object })
data = {};
| Type | Attribute → Property | Property → Attribute |
|---|---|---|
String |
String | String |
Number |
Number(attr) |
String(prop) |
Boolean |
attr !== null |
prop ? '' : null |
Array |
JSON.parse(attr) |
JSON.stringify() |
Object |
JSON.parse(attr) |
JSON.stringify() |
Custom converters
const dateConverter = {
fromAttribute: (value) => {
return value ? new Date(value) : null;
},
toAttribute: (value) => {
return value ? value.toISOString() : null;
}
};
@property({ converter: dateConverter })
createdAt = new Date();
Styling
static styles
import { LitElement, html, css } from "lit";
export class MyElement extends LitElement {
static styles = css`
:host {
display: block;
padding: 16px;
background: white;
}
h1 {
color: blue;
}
`;
render() {
return html`<h1>Styled!</h1>`;
}
}
Styles are scoped to Shadow DOM
:host selector
/* The component itself */
:host {
display: block;
}
/* When component has attribute */
:host([disabled]) {
opacity: 0.5;
}
/* Host in specific context */
:host(.active) {
border: 2px solid blue;
}
/* Host within parent selector */
:host-context(.dark-theme) {
background: black;
}
::slotted selector
/* Style slotted children */
::slotted(*) {
font-family: sans-serif;
}
/* Specific elements */
::slotted(h1) {
color: blue;
}
/* Named slots */
::slotted([slot="icon"]) {
width: 24px;
height: 24px;
}
⚠️ Note: ::slotted() only selects direct children, not descendants
CSS custom properties
/* Define custom properties */
:host {
--primary-color: blue;
--spacing: 16px;
}
/* Use custom properties */
.button {
background: var(--primary-color, #000);
padding: var(--spacing);
}
/* Expose for external styling */
:host {
background: var(--my-element-bg, white);
color: var(--my-element-color, black);
}
Multiple stylesheets
static styles = [
baseStyles,
css`
:host {
display: block;
}
`
];
Dynamic styling
import { styleMap } from 'lit/directives/style-map.js';
import { classMap } from 'lit/directives/class-map.js';
render() {
const classes = {
active: this.active,
disabled: this.disabled
};
const styles = {
color: this.color,
'font-size': `${this.size}px`
};
return html`
<div class=${classMap(classes)} style=${styleMap(styles)}>
Content
</div>
`;
}
Lifecycle methods
connectedCallback
connectedCallback() {
super.connectedCallback();
// Called when element is added to DOM
this._resizeObserver = new ResizeObserver(() => {
this.handleResize();
});
this._resizeObserver.observe(this);
}
Use for: Setup, event listeners, observers
disconnectedCallback
disconnectedCallback() {
super.disconnectedCallback();
// Called when element is removed from DOM
this._resizeObserver?.disconnect();
window.removeEventListener('resize', this.handleResize);
}
Use for: Cleanup, removing listeners
render
render() {
// Called when properties/state change
// Return template to render
return html`
<div>${this.message}</div>
`;
}
Pure function - No side effects, just return template
updated
updated(changedProperties) {
super.updated(changedProperties);
// Called after render() and DOM update
if (changedProperties.has('active')) {
this.focusInput();
}
}
Use for: Post-render DOM operations, analytics
firstUpdated
firstUpdated() {
// Called after first render only
this.input = this.renderRoot.querySelector('input');
this.input?.focus();
}
Use for: One-time setup, query DOM nodes
Lifecycle order
1. constructor()
2. connectedCallback()
3. render()
4. firstUpdated() // First time only
5. updated()
6. disconnectedCallback() // On removal
Event handling
Event listeners
render() {
return html`
<button @click=${this.handleClick}>Click</button>
<input @input=${this.handleInput}>
<form @submit=${this.handleSubmit}>
`;
}
handleClick(e: Event) {
console.log('Clicked!', e.target);
}
handleInput(e: InputEvent) {
this.value = (e.target as HTMLInputElement).value;
}
handleSubmit(e: Event) {
e.preventDefault();
// Handle form
}
Event options
html`
<!-- Capture phase -->
<div @click=${this.handleClick} @click=${{ capture: true }}></div>
<!-- Once (auto-remove after first call) -->
<button @click=${{ handleEvent: this.handleClick, once: true }}>
Click once
</button>
<!-- Passive (for scroll performance) -->
<div @touchstart=${{ handleEvent: this.handleTouch, passive: true }}></div>
`;
Custom events
// Dispatch custom event
_notifySelection() {
const event = new CustomEvent('item-selected', {
detail: { id: this.selectedId },
bubbles: true,
composed: true // Cross shadow boundary
});
this.dispatchEvent(event);
}
// Listen for custom event
html`
<child-element @item-selected=${this.handleSelection}>
</child-element>
`
⚠️ Note: Set composed: true to allow event to cross shadow DOM boundaries
Binding event listeners
constructor() {
super();
// Bind methods if needed (arrow functions auto-bind)
this.handleClick = this.handleClick.bind(this);
}
// Or use arrow functions
handleClick = (e: Event) => {
console.log(this.count); // `this` is always MyElement
}
Slots
Default slot
// Component definition
render() {
return html`
<div class="wrapper">
<slot></slot>
</div>
`;
}
// Usage
<my-element>
<p>This content goes in the slot</p>
</my-element>
Named slots
// Component definition
render() {
return html`
<div class="card">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
`;
}
// Usage
<my-card>
<h1 slot="header">Title</h1>
<p>Default content</p>
<p slot="footer">Footer text</p>
</my-card>
Fallback content
render() {
return html`
<slot>
<p>Default fallback content</p>
</slot>
`;
}
Shows fallback if no slotted content provided
Slot change event
render() {
return html`
<slot @slotchange=${this.handleSlotChange}></slot>
`;
}
handleSlotChange(e: Event) {
const slot = e.target as HTMLSlotElement;
const nodes = slot.assignedNodes({ flatten: true });
console.log('Slotted nodes:', nodes);
}
Directives
classMap
import { classMap } from 'lit/directives/class-map.js';
render() {
const classes = {
active: this.active,
disabled: this.disabled,
'has-error': this.error
};
return html`
<div class=${classMap(classes)}>
Content
</div>
`;
}
styleMap
import { styleMap } from 'lit/directives/style-map.js';
render() {
const styles = {
color: this.color,
backgroundColor: this.bgColor,
fontSize: `${this.size}px`
};
return html`
<div style=${styleMap(styles)}>
Styled content
</div>
`;
}
repeat
import { repeat } from 'lit/directives/repeat.js';
render() {
return html`
<ul>
${repeat(
this.items,
(item) => item.id, // Key function
(item) => html`<li>${item.name}</li>`
)}
</ul>
`;
}
Better performance than map() for dynamic lists
when
import { when } from 'lit/directives/when.js';
render() {
return html`
${when(
this.user,
() => html`<p>Welcome ${this.user.name}</p>`,
() => html`<p>Please log in</p>`
)}
`;
}
choose
import { choose } from 'lit/directives/choose.js';
render() {
return html`
${choose(this.status, [
['loading', () => html`<p>Loading...</p>`],
['error', () => html`<p>Error!</p>`],
['success', () => html`<p>Success!</p>`]
], () => html`<p>Unknown status</p>`)}
`;
}
cache
import { cache } from 'lit/directives/cache.js';
render() {
return html`
${cache(this.view === 'home' ? html`<home-view></home-view>` :
this.view === 'profile' ? html`<profile-view></profile-view>` :
html`<404-view></404-view>`)}
`;
}
Caches DOM between view switches
ifDefined
import { ifDefined } from 'lit/directives/if-defined.js';
render() {
return html`
<input .value=${ifDefined(this.value)}>
<img src=${ifDefined(this.imageSrc)}>
`;
}
Only sets attribute if value is defined
live
import { live } from 'lit/directives/live.js';
render() {
return html`
<input .value=${live(this.value)}>
`;
}
Checks live DOM value before updating (for inputs)
ref
import { ref, createRef } from 'lit/directives/ref.js';
private inputRef = createRef<HTMLInputElement>();
render() {
return html`
<input ${ref(this.inputRef)}>
`;
}
focus() {
this.inputRef.value?.focus();
}
unsafeHTML
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
render() {
return html`
<div>${unsafeHTML(this.htmlString)}</div>
`;
}
⚠️ Warning: Only use with trusted content - XSS risk!
Build & distribution
TypeScript config
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2020", "DOM"],
"experimentalDecorators": true,
"useDefineForClassFields": false
}
}
Production build (Rollup)
// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import terser from "@rollup/plugin-terser";
import typescript from "@rollup/plugin-typescript";
export default {
input: "src/my-element.ts",
output: {
file: "dist/my-element.js",
format: "es",
sourcemap: true,
},
plugins: [typescript(), resolve(), terser()],
};
Package.json
{
"name": "my-element",
"version": "1.0.0",
"type": "module",
"main": "dist/my-element.js",
"module": "dist/my-element.js",
"types": "dist/my-element.d.ts",
"exports": {
".": "./dist/my-element.js"
},
"files": ["dist/"]
}
Vite config
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
build: {
lib: {
entry: "src/my-element.ts",
formats: ["es"],
},
rollupOptions: {
external: /^lit/,
},
},
});
Common patterns
Form-associated custom element
export class MyInput extends LitElement {
static formAssociated = true;
private _internals: ElementInternals;
@property() value = "";
constructor() {
super();
this._internals = this.attachInternals();
}
updated(changed: PropertyValues) {
if (changed.has("value")) {
this._internals.setFormValue(this.value);
}
}
}
Reactive controllers
import { ReactiveController, ReactiveControllerHost } from "lit";
class MouseController implements ReactiveController {
private host: ReactiveControllerHost;
x = 0;
y = 0;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener("mousemove", this._onMove);
}
hostDisconnected() {
window.removeEventListener("mousemove", this._onMove);
}
private _onMove = (e: MouseEvent) => {
this.x = e.clientX;
this.y = e.clientY;
this.host.requestUpdate();
};
}
// Usage
export class MyElement extends LitElement {
private mouse = new MouseController(this);
render() {
return html`Mouse: ${this.mouse.x}, ${this.mouse.y}`;
}
}
Context API
import { provide, consume } from "@lit/context";
import { createContext } from "@lit/context";
// Define context
export const userContext = createContext<User>("user");
// Provider
@customElement("app-root")
export class AppRoot extends LitElement {
@provide({ context: userContext })
user = { name: "John", id: 1 };
}
// Consumer
@customElement("user-profile")
export class UserProfile extends LitElement {
@consume({ context: userContext })
user?: User;
render() {
return html`<p>${this.user?.name}</p>`;
}
}
Task controller
import { Task } from "@lit/task";
export class MyElement extends LitElement {
@property() userId = "";
private _userTask = new Task(this, {
task: async ([userId]) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
args: () => [this.userId],
});
render() {
return this._userTask.render({
pending: () => html`<p>Loading...</p>`,
complete: (user) => html`<p>${user.name}</p>`,
error: (e) => html`<p>Error: ${e}</p>`,
});
}
}
Gotchas & tips
Property initialization
// ❌ Bad - Property not initialized
@property()
name: string;
// ✅ Good - Always initialize
@property()
name = '';
@property({ type: Array })
items: string[] = [];
⚠️ Note: Properties must have default values or TypeScript will error
Array/object mutation
// ❌ Bad - Mutating doesn't trigger update
this.items.push(newItem);
// ✅ Good - Create new array
this.items = [...this.items, newItem];
// ❌ Bad - Mutating object
this.user.name = "New name";
// ✅ Good - Create new object
this.user = { ...this.user, name: "New name" };
Constructor vs connectedCallback
constructor() {
super();
// ✅ Initialize properties
this.count = 0;
// ❌ Don't access attributes/children
// this.getAttribute('name'); // Not yet parsed
// ❌ Don't query DOM
// this.querySelector('div'); // Not yet in DOM
}
connectedCallback() {
super.connectedCallback();
// ✅ Access DOM, attributes
// ✅ Add event listeners
// ✅ Start observers
}
Shadow DOM vs Light DOM
// ❌ querySelector won't find shadow children
this.querySelector("button"); // null
// ✅ Use renderRoot or shadowRoot
this.renderRoot.querySelector("button");
this.shadowRoot.querySelector("button");
requestUpdate
// Manual update trigger (rarely needed)
someMethod() {
// Update external state
externalStore.update();
// Force re-render
this.requestUpdate();
}
// Or specify changed property
this.requestUpdate('propertyName', oldValue);
Boolean attributes
// ❌ Bad - String "false" is truthy
<my-element disabled="false"></my-element>
// ✅ Good - Presence = true, absence = false
<my-element disabled></my-element>
<my-element></my-element>
// In component
@property({ type: Boolean })
disabled = false; // Converts properly
CSS inheritance
/* ❌ Font won't inherit into shadow DOM */
body {
font-family: sans-serif;
}
/* ✅ Use inheritable properties on :host */
:host {
font-family: inherit;
}
/* Or use CSS custom properties */
:host {
font-family: var(--app-font, sans-serif);
}
Also see
- Lit.dev Official Docs (lit.dev)
- Lit API Reference (lit.dev)
- Lit Playground (lit.dev)
- Web Components MDN (developer.mozilla.org)
- Custom Elements Everywhere (custom-elements-everywhere.com)
- Open Web Components (open-wc.org)