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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- lit-web-components Installs to ~/.agents/skills/lit-web-components/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install lit-web-components The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project lit-web-components Installs to .agents/skills/lit-web-components/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/lit-web-components.zipunzip -o lit-web-components.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) lit-web-components PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md,package.jsonandtsconfig.json. Note the Lit major (lit@3), whether decorators are used, and the test runner (@web/test-runner, Vitest browser mode, Playwright). - Check the decorator mode, because it changes how fields are declared:
| tsconfig | Declare reactive fields as |
|---|---|
"experimentalDecorators": true and "useDefineForClassFields": false | @property() name = '' |
Standard (TC39) decorators, no experimentalDecorators | @property() accessor name = '' |
| Plain JS, no decorators | static 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
| Need | Use |
|---|---|
| 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 render | compute 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 callsuper. 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.updateCompletebefore 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 styleswithcsstagged 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 writeacme-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 stylesor 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: truefor events consumers listen to on ancestors; leavecomposed: falsefor 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.targetin a document listener is the host, usee.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 useElementInternalsARIA properties (this._internals.role = 'switch').
Framework interop
| Consumer | What to do |
|---|---|
| Plain HTML | Import the module once; set rich data via JS properties |
| React 19 | Works directly: props become properties when the element defines them; listen with on + exact event name, e.g. onacme-toggle={fn} |
| React 18 or older | Wrap with @lit/react createComponent({ tagName, elementClass, react: React, events: { onToggle: 'acme-toggle' } }) |
| Vue 3 | Tell the compiler: vue({ template: { compilerOptions: { isCustomElement: (t) => t.startsWith('acme-') } } }); use .prop modifier or :prop binding |
| Angular | Add CUSTOM_ELEMENTS_SCHEMA to the standalone component’s schemas; bind [prop] and (acme-toggle) |
| Svelte 5 | Works 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 callcustomElements.define, and types. - Do not bundle
litinto 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 withdocument.createElement; set host attributes such asroleortabindexinconnectedCallback. - Listeners added in
connectedCallbackonwindowwithout removal leak every instance. @queryreturnsnullbefore first render; use it afterfirstUpdatedorupdateComplete.unsafeHTMLwith user content is an XSS hole; sanitise or render text bindings.
Verification
- Typecheck passes (
npx tsc --noEmit) andHTMLElementTagNameMapentry exists - Setting the attribute and the property each re-render; reflected attributes appear
- Events fire once, carry
detail, and are caught ondocumentwhen composed - Theming works via custom properties and
::partfrom 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.yaml
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.