NexusCS

Lit

Web Components
Quick reference for Lit - Simple, fast, web components library. Build encapsulated, reusable components with reactive properties, declarative templates, and scoped styles.
featured

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 Cheatsheet - NexusCS