Skip to content

Custom Events

Custom events are how a Lit component communicates outward to whatever is listening, without knowing or caring who that listener is. This lesson covers creating, dispatching, and naming them well.

Dispatching a CustomEvent

CustomEvent is a native browser constructor (not something Lit provides) that lets you create an event carrying arbitrary structured data in its detail property. Calling this.dispatchEvent(event) fires it from your component, exactly like the browser fires a native click event.

Any ancestor (including the element itself) can listen for this event using addEventListener directly, or — inside another Lit component's template — with the same @event-name=${handler} binding syntax used for native events.

class StarRating extends LitElement {
  @property({ type: Number }) value = 0;

  #setRating(newValue) {
    this.value = newValue;
    this.dispatchEvent(
      new CustomEvent('rating-changed', {
        detail: { value: newValue },
        bubbles: true,
        composed: true,
      })
    );
  }

  render() {
    return html`
      ${[1, 2, 3, 4, 5].map(
        (n) => html`<button @click=${() => this.#setRating(n)}>${n <= this.value ? '\u2605' : '\u2606'}</button>`
      )}
    `;
  }
}

bubbles: true lets the event travel up through ancestor elements; composed: true lets it also cross out of this component's own Shadow DOM boundary.

CustomEvent Constructor Options

new CustomEvent('event-name', {
  detail: { /* any structured data */ },
  bubbles: true,   // travels up through ancestors
  composed: true,  // crosses shadow DOM boundaries
  cancelable: false, // whether listeners can call preventDefault()
});
  • detail can hold any value — an object, array, string, or number — and is the standard place to attach event data.
  • bubbles: true is almost always what you want for component-to-parent communication.
  • composed: true is required if any listener sits outside this component's own Shadow DOM (covered in depth in the next lesson).
  • Event names are conventionally lowercase and hyphenated, mirroring native event naming conventions.

Custom Event Cheat Sheet

The options and patterns you'll use for almost every dispatched event.

Option/Pattern Typical Value Why
bubbles true So ancestors further up the tree can listen
composed true So the event escapes this component's Shadow DOM
detail A small plain object Carries structured data with the event
Naming lowercase-hyphenated Matches native event naming conventions
Listening in a parent template @rating-changed=${this.#onRatingChanged} Same syntax as native events

Designing the detail Payload

Treat detail like a small, stable API contract, exactly as you would a function's return value. Keep it a plain, serializable object with clearly named fields, and avoid including internal implementation details (like private component instances) that consumers shouldn't depend on.

// Good: small, clear, stable shape
this.dispatchEvent(new CustomEvent('item-removed', {
  detail: { id: item.id },
  bubbles: true,
  composed: true,
}));

// Avoid: leaking internal state or DOM references unnecessarily
this.dispatchEvent(new CustomEvent('item-removed', {
  detail: { internalCache: this.#cache, domNode: this.shadowRoot },
}));

Listening from Plain JavaScript (No Lit)

Because these are just native events, any plain JavaScript code — not only another Lit component's template — can listen with addEventListener, which is exactly what makes Lit components usable from React, Vue, Angular, or vanilla HTML pages.

document.querySelector('star-rating')
  .addEventListener('rating-changed', (event) => {
    console.log('New rating:', event.detail.value);
  });

Common Mistakes

  • Forgetting bubbles: true, so only a listener attached directly to the dispatching element (not an ancestor) ever hears the event.
  • Putting non-serializable or overly internal data (DOM nodes, private class instances) inside detail.
  • Naming custom events inconsistently (mixing camelCase and kebab-case) across a component library.
  • Dispatching an event but never documenting its detail shape, making the component's public event API unclear to consumers.

Key Takeaways

  • CustomEvent is a native browser API for creating events with a structured detail payload.
  • bubbles: true and composed: true are needed for most component-to-ancestor communication.
  • Design detail payloads as a small, stable public contract, not an internal state dump.
  • Because custom events are native, they're listenable from any framework or plain JavaScript.

Pro Tip

Document every custom event your component dispatches (name, detail shape, when it fires) exactly as thoroughly as you document its properties — from a consumer's perspective, events are just as much a part of your public API.