Lit Web Components skill for Codex

Custom elements that work in every framework. Codex checks your decorator and tsconfig setup before declaring a single property, so fields actually trigger re-renders. Components get a documented API of attributes, events, slots, parts and CSS custom properties, emit composed events that escape the shadow root, join native forms through ElementInternals, and come with real-browser tests plus the exact wiring each framework needs.

  • Frontend
  • $lit-web-components
  • 240 lines
  • 2 files
  • ~2,700 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- lit-web-components

Installs to ~/.agents/skills/lit-web-components/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $lit-web-components, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds framework-agnostic custom elements with Lit 3 - reactive properties, shadow DOM styling, slots, ::part theming, composed events, form-associated controls and SSR - and wires them into React, Vue, Angular and plain HTML without broken props or lost events. Use when writing or reviewing a LitElement, publishing a web component library or design system, fixing properties that do not update, styling through a shadow root, or consuming custom elements from a framework.

Use it when

  • Writing or reviewing a LitElement or a vanilla custom element
  • Publishing a framework-agnostic design system or widget
  • Theming components through a shadow root with parts and custom properties
  • Consuming custom elements from React, Vue, Angular or Svelte

Use something else when

  • You need accessible primitives inside a React-only app: use radix-ui
  • The question is general ARIA widget behaviour rather than Lit: use aria-patterns

How to check the result

Browser tests confirm that both attribute and property changes re-render, events carry detail and reach document listeners, and an axe check passes on the rendered component.

The mistake it prevents

Using experimental decorators with useDefineForClassFields left on, so class fields shadow Lit's reactive accessors and nothing ever re-renders.

~/.agents/skills/lit-web-components/SKILL.md

--- name: lit-web-components description: Builds framework-agnostic custom elements with Lit 3 - reactive properties, shadow DOM styling, slots, ::part theming, composed events,… license: MIT ---

A good Lit component works in plain HTML first: attributes and properties both set state, it emits documented events that cross the shadow boundary, consumers can theme it without forking it, and it cleans up everything it attached when it leaves the DOM.

Before writing code

  1. Read AGENTS.md, package.json and tsconfig.json. Note the Lit major (lit@3), whether decorators are used, and the test runner (@web/test-runner, Vitest browser mode, Playwright).
  2. Check the decorator mode, because it changes how fields are declared:
tsconfigDeclare reactive fields as
"experimentalDecorators": true and "useDefineForClassFields": false@property() name = ''
Standard (TC39) decorators, no experimentalDecorators@property() accessor name = ''
Plain JS, no decoratorsstatic properties = { name: { type: String } } and assign in the constructor

With experimental decorators, useDefineForClassFields: true (the default for target >= ES2022) makes class fields shadow Lit’s accessors and nothing re-renders. 3. List the public API before coding: attributes, properties, events, slots, CSS custom properties and parts. That list becomes the docs and the custom-elements manifest.

Component skeleton

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';

@customElement('acme-disclosure')
export class AcmeDisclosure extends LitElement {
  static styles = css`
    :host { display: block; }
    :host([hidden]) { display: none; }
    button {
      font: inherit;
      color: var(--acme-disclosure-color, currentColor);
    }
  `;

  /** Heading shown on the toggle button. */
  @property() summary = '';
  /** Reflected so consumers can style `acme-disclosure[open]`. */
  @property({ type: Boolean, reflect: true }) open = false;

  @state() private _animating = false;
  @query('#panel') private _panel!: HTMLElement;

  private _toggle() {
    this.open = !this.open;
    this.dispatchEvent(new CustomEvent('acme-toggle', {
      detail: { open: this.open }, bubbles: true, composed: true,
    }));
  }

  render() {
    return html`
      <button part="button" aria-expanded=${this.open} aria-controls="panel"
        @click=${this._toggle}>${this.summary}</button>
      <div id="panel" part="panel" ?hidden=${!this.open}>
        <slot></slot>
      </div>
      ${this._animating ? html`<span class="sr-only">Loading</span>` : nothing}
    `;
  }
}

declare global {
  interface HTMLElementTagNameMap { 'acme-disclosure': AcmeDisclosure; }
}

Binding syntax: attr=${v} attribute, .prop=${v} property, ?bool=${v} boolean attribute, @event=${fn} listener (auto-bound to the host). Pass objects and arrays with .prop, never through attributes.

Property decisions

NeedUse
Public input settable from HTML@property() with the right type (String default, Number, Boolean, Array, Object)
Consumers style on state ([open])reflect: true; never reflect objects or large arrays
Internal state that re-renders@state(), private, not part of the API
Attribute name differs from camelCase prop@property({ attribute: 'max-items' }) maxItems = 10
Derived value before rendercompute in willUpdate(changed), not in updated
Custom equality (avoid re-render on same object)hasChanged: (a, b) => a?.id !== b?.id

Lit only re-renders when a property is reassigned. this.items.push(x) does nothing; write this.items = [...this.items, x].

Lifecycle rules

  • connectedCallback / disconnectedCallback: add and remove global listeners, observers and timers here. Always call super. Elements can be moved and reconnected, so set up again on reconnect rather than once in the constructor.
  • willUpdate(changed): derive state from changed properties; setting properties here does not trigger an extra update.
  • firstUpdated(): one-time DOM work (focus, measuring).
  • updated(changed): side effects that need the rendered DOM. Setting a reactive property here schedules another update: guard it or you loop.
  • await el.updateComplete before reading rendered DOM in tests or parent code.

For async data, use @lit/task (new Task(this, { task, args }) and task.render({...})) instead of hand-rolled promises in connectedCallback; it handles races and aborts. For dependency injection across a tree (theme, i18n, store), use @lit/context.

Styling and theming

  • static styles with css tagged literals: parsed once, shared via adoptedStyleSheets.
  • Expose theming through CSS custom properties with component-prefixed names and a fallback: var(--acme-button-bg, #1f6feb). Custom properties inherit through the shadow boundary; ordinary selectors do not.
  • Expose structural styling with part="..." so consumers write acme-disclosure::part(button) { ... }. Parts are API: renaming one is a breaking change.
  • ::slotted(*) styles only top-level slotted nodes and loses to page styles; keep it light.
  • Set :host { display: block } (custom elements default to inline) and honour :host([hidden]).
  • Global stylesheets, Tailwind utilities and CSS resets do not reach inside the shadow root. If the design system is utility-based, either adopt a shared constructed stylesheet in static styles or render into light DOM (createRenderRoot() { return this; }) and accept losing slots and encapsulation.

Events

  • Name events with a prefix (acme-toggle) to avoid collisions with native events.
  • bubbles: true, composed: true for events consumers listen to on ancestors; leave composed: false for internal events.
  • Put data in detail; never make consumers query the element’s shadow DOM.
  • Native events from inside the shadow root are retargeted to the host. e.target in a document listener is the host, use e.composedPath() for the inner node.

Forms and accessibility

Form-associated custom elements participate in <form> submission and validation:

static formAssociated = true;
private _internals = this.attachInternals();

@property() value = '';
@query('input') private _input!: HTMLInputElement;

// updated, not willUpdate: the validity anchor must already be rendered
updated(changed: Map<string, unknown>) {
  if (changed.has('value')) {
    this._internals.setFormValue(this.value);
    this._internals.setValidity(
      this.value ? {} : { valueMissing: true }, 'Required', this._input);
  }
}
formResetCallback() { this.value = ''; }
  • Use delegatesFocus: true (static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }) for wrappers around a single focusable control.
  • ARIA ids cannot cross shadow roots: a <label for> outside cannot point at an input inside. Keep label and control in the same root, or use ElementInternals ARIA properties (this._internals.role = 'switch').

Framework interop

ConsumerWhat to do
Plain HTMLImport the module once; set rich data via JS properties
React 19Works directly: props become properties when the element defines them; listen with on + exact event name, e.g. onacme-toggle={fn}
React 18 or olderWrap with @lit/react createComponent({ tagName, elementClass, react: React, events: { onToggle: 'acme-toggle' } })
Vue 3Tell the compiler: vue({ template: { compilerOptions: { isCustomElement: (t) => t.startsWith('acme-') } } }); use .prop modifier or :prop binding
AngularAdd CUSTOM_ELEMENTS_SCHEMA to the standalone component’s schemas; bind [prop] and (acme-toggle)
Svelte 5Works natively; properties are set when defined on the element

Guard double registration when several bundles may include the component: if (!customElements.get('acme-disclosure')) customElements.define(...). Two copies of Lit on a page work but cost bytes; dedupe in the consuming bundler.

SSR

@lit-labs/ssr renders declarative shadow DOM; frameworks integrate via @lit-labs/nextjs or Astro’s Lit integration. Hydrate by importing @lit-labs/ssr-client/lit-element-hydrate-support.js before any component module. Keep render() free of window/document access; move that to firstUpdated.

Publishing a component library

  • Ship ESM only, "type": "module", "sideEffects" listing the files that call customElements.define, and types.
  • Do not bundle lit into the library output; list it as a dependency.
  • Generate a custom-elements manifest (npx @custom-elements-manifest/analyzer analyze --litelement) for docs, Storybook and IDE autocompletion. Needs network the first time if not installed.

Testing

import { fixture, html, expect } from '@open-wc/testing';
import './acme-disclosure.js';

it('emits acme-toggle and reflects open', async () => {
  const el = await fixture<AcmeDisclosure>(html`<acme-disclosure summary="More"></acme-disclosure>`);
  const events: CustomEvent[] = [];
  el.addEventListener('acme-toggle', (e) => events.push(e as CustomEvent));
  el.shadowRoot!.querySelector('button')!.click();
  await el.updateComplete;
  expect(el.hasAttribute('open')).to.be.true;
  expect(events[0].detail.open).to.be.true;
  await expect(el).to.be.accessible();
});

Run in a real browser (Web Test Runner or Vitest browser mode); jsdom’s shadow DOM and ElementInternals support is incomplete enough to give false passes.

Gotchas

  • Class-field shadowing (wrong decorator config) is the top “property does not update” cause.
  • Calling this.setAttribute(...) or adding children in the constructor violates the custom element spec and throws when the element is made with document.createElement; set host attributes such as role or tabindex in connectedCallback.
  • Listeners added in connectedCallback on window without removal leak every instance.
  • @query returns null before first render; use it after firstUpdated or updateComplete.
  • unsafeHTML with user content is an XSS hole; sanitise or render text bindings.

Verification

  • Typecheck passes (npx tsc --noEmit) and HTMLElementTagNameMap entry exists
  • Setting the attribute and the property each re-render; reflected attributes appear
  • Events fire once, carry detail, and are caught on document when composed
  • Theming works via custom properties and ::part from a page stylesheet
  • Removing the element from the DOM leaves no listeners (check with DevTools or a test)
  • Browser tests and an axe accessibility assertion pass

Output

When delivering a component, report its API as a table: attributes/properties (type, default, reflects), events (name, detail), slots, CSS custom properties, parts.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Lit Web Components"
  short_description: "Custom elements that work in every framework"
  default_prompt: "$lit-web-components Build a Lit date-range picker that submits with a normal HTML form"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem