Skip to content

Instantly share code, notes, and snippets.

@plasticmind
Created February 20, 2026 17:24
Show Gist options
  • Select an option

  • Save plasticmind/102225f08f4c03aefcb11500eb0f5245 to your computer and use it in GitHub Desktop.

Select an option

Save plasticmind/102225f08f4c03aefcb11500eb0f5245 to your computer and use it in GitHub Desktop.
Lit Component Claude Code Skill
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.

Lit Web Components

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/

Quick Start

Minimal component (TypeScript with decorators)

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>`;
  }
}

Minimal component (JavaScript, no decorators)

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);

When to Use

  • Creating a new Lit web component
  • Adding reactive properties or internal state
  • Writing templates with html tagged literals
  • Styling components with css tagged 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

Instructions

Create a new Lit component

  1. Import from lit and lit/decorators.js
  2. Extend LitElement
  3. Add @customElement('tag-name') decorator (or customElements.define())
  4. Add static styles with css tagged template
  5. Add reactive properties with @property() or @state()
  6. Implement render() returning html tagged template
  7. Add TypeScript HTMLElementTagNameMap declaration for type safety

Add a reactive property

  1. Choose decorator:
    • @property() — public API, syncs with attribute
    • @state() — internal only, no attribute
  2. Set options: type, attribute, reflect, hasChanged
  3. Initialize with default value
  4. Use in render() template

Dispatch a custom event

  1. Create new CustomEvent('event-name', { detail, bubbles: true, composed: true })
  2. Await this.updateComplete if DOM must reflect state first
  3. Call this.dispatchEvent(event)

Add a slot

  1. Add <slot></slot> (default) or <slot name="name"></slot> (named) in template
  2. Style slotted content with ::slotted(selector)
  3. Query with @queryAssignedElements() or slot.assignedElements()

Component Definition

Full Structure (TypeScript)

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;
  }
}

TypeScript Configuration

{
  "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.

Reactive Properties

@property vs @state

Decorator Attribute External Access Use Case
@property() Yes (observed) Public API Props passed by consumers
@state() No Internal only Private component state

Property Options

@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 Conversion (Default Converter)

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

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;

Immutable Updates

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);

Templates

The render() Method

render() {
  return html`
    <h1>${this.title}</h1>
    <p class=${this.highlight ? 'highlight' : ''}>Content</p>
    <input .value=${this.inputValue}>
    <button @click=${this._handleClick}>Click</button>
  `;
}

Expression Syntax

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}>

Conditionals

// 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}">

Lists

// 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)

Renderable Values

The render() method can return:

  • Primitives (string, number, boolean)
  • TemplateResult from html
  • DOM Nodes
  • nothing (renders nothing)
  • noChange (skips update)
  • Arrays/iterables of the above

Styles

Static Styles (Recommended)

static styles = css`
  :host {
    display: block;
    --my-color: blue;
  }

  :host([disabled]) {
    opacity: 0.5;
  }

  .container {
    color: var(--my-color);
    background: var(--my-bg, white);
  }
`;

Multiple Style Sheets

static styles = [
  sharedStyles,
  css`/* component-specific styles */`
];

Key Selectors

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

CSS Custom Properties for Theming

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;
}

Dynamic Styles

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>
  `;
}

Lifecycle

Reactive Update Cycle

Property change → requestUpdate() → shouldUpdate() → willUpdate()
    → update() → render() → firstUpdated()/updated() → updateComplete ✓

Key Lifecycle Methods

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();
  }
}

Waiting for Updates

async someMethod() {
  this.prop = 'new value';
  await this.updateComplete;
  // DOM is now updated
}

Events

Listening to Events

render() {
  return html`
    <button @click=${this._onClick}>Click</button>
    <input @input=${this._onInput}>
  `;
}

private _onClick(e: Event) {
  // 'this' is automatically bound to the component
}

Event Options

import { eventOptions } from 'lit/decorators.js';

@eventOptions({ passive: true, capture: true })
private _onScroll(e: Event) { }

Dispatching Custom Events

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();
}

Event Bubbling & Composition

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.

Shadow DOM & Slots

Default Slot

render() {
  return html`
    <div class="wrapper">
      <slot></slot>  <!-- Renders children -->
    </div>
  `;
}

Named Slots

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 Fallback Content

<slot>Default content if nothing slotted</slot>

Note: Fallback won't render if any nodes are assigned—including whitespace text nodes.

Querying Slotted Content

@queryAssignedElements({ slot: 'items', selector: '.item' })
_items!: Array<HTMLElement>;

// Or manually
get _slottedChildren() {
  const slot = this.shadowRoot?.querySelector('slot');
  return slot?.assignedElements({ flatten: true }) ?? [];
}

Query Decorators

@query('#myButton')
_button!: HTMLButtonElement;

@queryAll('.item')
_items!: NodeListOf<HTMLElement>;

@queryAsync('#lazyElement')
_lazyEl!: Promise<HTMLElement>;

Reactive Controllers

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>`;
  }
}

Controller Lifecycle Hooks

Method When Called
hostConnected() Host's connectedCallback
hostDisconnected() Host's disconnectedCallback
hostUpdate() Before host update/render
hostUpdated() After host update completes

SSR Considerations

Lit supports server-side rendering via @lit-labs/ssr. For SSR-compatible components:

  1. Use shadow DOM (required for Lit SSR)
  2. Avoid async work in render — keep render() synchronous
  3. Use willUpdate() for computed values — it runs on both server and client
  4. Defer DOM-dependent logic to firstUpdated() — only runs in browser

Best Practices

DO

  • Use static styles for 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 nothing to conditionally remove nodes/attributes
  • Create new object/array references to trigger updates
  • Call super in lifecycle methods

DON'T

  • 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 @event syntax
  • Don't skip super.connectedCallback() / super.disconnectedCallback()
  • Don't access DOM in constructor—use firstUpdated() instead
  • Don't default boolean properties to true

Common Patterns

Form Integration

@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}>`;
}

Forwarding Focus

static shadowRootOptions = {
  ...LitElement.shadowRootOptions,
  delegatesFocus: true
};

Lazy Loading

@queryAsync('#heavy-component')
_heavyComponent!: Promise<HeavyComponent>;

async activateHeavy() {
  const component = await this._heavyComponent;
  component.activate();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment