Claude Code with Lit: Web Components That Compound
Why Lit without CLAUDE.md generates framework-style code that fights the platform
Lit is built on browser-native Custom Elements. The browser registers the element, the browser calls lifecycle callbacks, the browser manages the shadow DOM. Lit's job is to make that surface ergonomic with decorators, reactive properties, and a tagged-template renderer. The underlying model is the web platform, not a framework runtime.
Claude Code does not know this by default. Without explicit constraints, Claude treats a Lit component like a React component: it generates class anatomy with render() called from componentDidMount-style hooks, wraps html\`literals in standalone functions that are then called insiderender(), forgets the static properties` declaration that Lit needs before reactive updates trigger, and sometimes emits JSX outright because the project has TypeScript configured and Claude assumes JSX is the template format.
The result is a component that compiles but does not update. A property changes, nothing re-renders. An event fires, the handler is not wired. A slot is passed content, the content disappears into the void. None of this is a Lit bug. It is Claude generating code against a mental model of React rather than the Lit reactive update cycle.
This guide covers the CLAUDE.md configuration that anchors Claude Code to Lit 3's actual model: @customElement for registration, @property and @state for reactive data, html\`tagged templates for rendering,css``static styles for scoped CSS via adoptedStyleSheets, slots for composition, and@lit-labs/ssr` for server rendering. If you are new to Claude Code itself, the Claude Code setup guide covers installation. For TypeScript conventions that sit underneath this guide, Claude Code with TypeScript covers the strict-mode configuration that Lit projects benefit from.
The Lit CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Lit component library or application it needs to declare: Lit version, the decorator pattern for element registration, the reactive property and state system, the template syntax, the styling model, slot usage, SSR setup if relevant, and the hard rules that block the patterns Claude generates most often without guidance.
# Lit web components rules
## Stack
- Lit 3.x, TypeScript 5.x strict
- Vite 5.x with the Lit Vite plugin (or Rollup + @web/dev-server)
- @lit-labs/ssr for server rendering (if applicable)
- No React, no JSX, no virtual DOM
## Element registration
- ALWAYS use the @customElement decorator: @customElement('my-button')
- NEVER call customElements.define() manually unless wrapping a non-decorator class
- Element tag names MUST contain a hyphen: my-button, app-header, ui-card
- One element class per file; file name matches the tag name: my-button.ts
## Reactive properties
- @property(): public reactive properties; changes re-render the element and reflect to attributes
- @state(): private reactive state; changes re-render but do NOT reflect to attributes
- ALWAYS declare @property type option: @property({ type: Boolean })
- Attribute binding: reflect: true only when the attribute value needs to be readable by CSS selectors
- NEVER mutate arrays or objects in place. Assign a new reference to trigger re-render:
this.items = [...this.items, newItem] // correct
this.items.push(newItem) // WRONG, no re-render
## Template syntax (html`` tagged template literals)
- ALWAYS use html`` not JSX, not createElement, not innerHTML
- Event listeners: @click=${this.handleClick} (@ prefix, no addEventListener calls)
- Boolean attributes: ?disabled=${this.loading} (? prefix)
- Property bindings: .value=${this.inputValue} (. prefix)
- Child template references: ${this.renderCard()} or ${html`<slot></slot>`}
- Conditionals: ${this.show ? html`<span>visible</span>` : nothing}
- Lists: ${this.items.map(item => html`<li>${item.label}</li>`)}
- Import nothing from lit: import { nothing } from 'lit';
## Styling
- ALWAYS use static styles = css`...` class field, never inline <style> tags
- CSS lives in the shadow DOM; selectors inside static styles are scoped automatically
- Use CSS custom properties (--button-bg: var(--brand-primary, #324fff)) for theming
- :host selector targets the custom element itself: :host { display: block; }
- :host([disabled]) targets the element when the disabled attribute is present
- NEVER use document.querySelector or global CSS inside a component
- adoptedStyleSheets is handled by Lit automatically; do not call it manually
- @scope (native CSS) is NOT yet baseline; use :host and ::slotted() instead
## Slots and composition
- Default slot: <slot></slot> in the template
- Named slot: <slot name="header"></slot>
- Consumer usage: <my-card><h2 slot="header">Title</h2><p>Body</p></my-card>
- ::slotted(p): CSS selector targeting slotted content; only one level deep
- NEVER access slot content via this.shadowRoot.querySelector; use slotchange event instead
- assignedElements() or assignedNodes() on the slot element to read slotted content
## Lifecycle
- connectedCallback(), element added to DOM (equivalent: componentDidMount)
- disconnectedCallback(), element removed from DOM (cleanup subscriptions, timers)
- attributeChangedCallback(), handled automatically by @property; do NOT override unless wrapping
- updated(changedProperties), called after every update; use for side effects on property changes
- firstUpdated(), called once after first render; use for DOM queries (this.renderRoot)
- NEVER access this.shadowRoot before firstUpdated or connectedCallback
## Querying the shadow DOM
- @query('#my-input') input!: HTMLInputElement; , lazy accessor, prefer over this.shadowRoot.querySelector
- @queryAll('.item') items!: NodeListOf<HTMLElement>;
- Import from 'lit/decorators.js'
## Hard rules
- NEVER use JSX in a Lit file, TSX extension is forbidden in this project
- NEVER use innerHTML to set content on shadow DOM elements
- NEVER call this.requestUpdate() manually unless updating outside the reactive cycle
- NEVER write render() as a standalone function and call it, render() is the override point
- NEVER use document-level event listeners, use this.addEventListener for host events
- NEVER use React lifecycle names (componentDidMount, etc.), use Lit equivalents
- Imports: import { LitElement, html, css, nothing } from 'lit'
- Decorators: import { customElement, property, state, query } from 'lit/decorators.js'
Two rules here prevent the majority of issues Claude generates without them.
The immutable property update rule is the most consequential entry. Lit's reactive system tracks property assignment. When you call this.items.push(newItem), the array reference does not change, Lit sees no assignment, and no re-render fires. The fix is always to assign a new reference: this.items = [...this.items, newItem]. Claude generates .push() and .splice() by default because those are the idiomatic JavaScript mutations. The rule removes that option.
The JSX hard block is necessary because many TypeScript projects have jsx: react in tsconfig.json for adjacent tooling. Claude reads the tsconfig and assumes JSX is the template format. In a Lit project, the template format is html\`` tagged template literals. The hard block plus the explicit import list ensures Claude stays in the correct syntax.
Project structure and tooling
A Lit component library has a predictable structure. Declaring it in CLAUDE.md means Claude generates new files in the right location and imports from the right paths without prompting.
Add a project structure section to CLAUDE.md:
## Project structure
- src/components/ , element files, one per custom element (my-button.ts)
- src/styles/ , shared CSS custom properties (design-tokens.css)
- src/utils/ , pure functions, no DOM dependencies
- src/index.ts , barrel export for the library
- demo/ , Vite dev server HTML files for manual testing
- stories/ , Storybook stories if applicable
## Vite config (vite.config.ts)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: (format) => `my-library.${format}.js`,
},
rollupOptions: {
external: ['lit'], // do not bundle Lit, consumers provide it
},
},
});
## TypeScript config (tsconfig.json)
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"useDefineForClassFields": false, // required for Lit decorators
"experimentalDecorators": true,
"strict": true
}
}
## Critical tsconfig note
useDefineForClassFields: false is REQUIRED for Lit decorators to function correctly.
With it set to true, class fields initialise before decorators run, breaking @property and @state.
NEVER remove this line or set it to true.
The useDefineForClassFields: false setting is the most frequently missed configuration item in Lit projects. When TypeScript compiles class fields as native Object.defineProperty assignments (the true behaviour), the field is set before the @property decorator runs, which means the decorator cannot install its reactive getter/setter. The result is a property that is set but never causes a re-render. The rule in CLAUDE.md prevents Claude from generating a tsconfig that omits this flag.
The @customElement decorator pattern
The @customElement decorator is the correct registration path for Lit 3. It calls customElements.define() with the tag name and the class in one step. Claude will sometimes generate customElements.define('my-button', MyButton) at the bottom of the file, which works but is not idiomatic Lit and breaks the one-file-one-element convention when the file is imported as a side effect.
A complete Lit element with correct anatomy:
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state, query } from 'lit/decorators.js';
@customElement('my-button')
export class MyButton extends LitElement {
static styles = css`
:host {
display: inline-block;
}
button {
background: var(--button-bg, #324fff);
color: var(--button-color, #fff);
border: none;
border-radius: 6px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
:host([disabled]) button {
opacity: 0.5;
cursor: not-allowed;
}
`;
@property({ type: String }) label = 'Click me';
@property({ type: Boolean, reflect: true }) disabled = false;
@state() private loading = false;
@query('button') private buttonEl!: HTMLButtonElement;
private async handleClick() {
if (this.disabled || this.loading) return;
this.loading = true;
this.dispatchEvent(new CustomEvent('my-button-click', { bubbles: true, composed: true }));
await this.updateComplete;
this.loading = false;
}
render() {
return html`
<button
?disabled=${this.disabled || this.loading}
@click=${this.handleClick}
>
${this.loading ? html`<span>Loading...</span>` : this.label}
</button>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'my-button': MyButton;
}
}
Three details here are worth noting as CLAUDE.md rules. The bubbles: true, composed: true on the CustomEvent is required for events to cross the shadow DOM boundary. Without composed: true, a listener on a parent element outside the shadow root never fires. Claude omits composed: true by default. The declare global block at the bottom registers the element type in TypeScript's element tag name map, giving consumers correct types when they use document.querySelector('my-button'). And reflect: true on disabled means the attribute is written back to the DOM when the property is set programmatically, which is what makes :host([disabled]) CSS work.
Add these as explicit rules in CLAUDE.md:
## CustomEvent rules
- ALWAYS pass bubbles: true, composed: true for events meant to reach outside the shadow DOM
- Prefix event names with the element name: my-button-click not just click
- Dispatch from the host element (this.dispatchEvent), not from an inner element
- ALWAYS add the declare global HTMLElementTagNameMap block at the bottom of every element file
## Property reflection rules
- reflect: true on Boolean properties used in CSS :host([attr]) selectors, REQUIRED
- reflect: true on String properties used for CSS attribute selectors, optional, use with intent
- NEVER set reflect: true on Object or Array properties (attribute serialisation is lossy)
Reactive properties and @state
@property and @state are the two reactive primitives in Lit. The distinction is not optional: @property is for data that comes in from outside the component (via HTML attribute or JavaScript property assignment), and @state is for data that lives entirely inside the component.
Add a reactive data section to CLAUDE.md:
## Reactive data patterns
### @property, external data
@property({ type: String }) value = '';
@property({ type: Number }) count = 0;
@property({ type: Boolean, reflect: true }) active = false;
@property({ type: Array }) items: string[] = [];
@property({ type: Object }) config: Config = {};
### @state, internal data
@state() private expanded = false;
@state() private selectedIndex = -1;
@state() private cache = new Map<string, string>();
### Responding to property changes
updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('value')) {
this.processValue(this.value);
}
}
### Async updates
// await this.updateComplete to read DOM after a reactive update
async submit() {
this.loading = true;
await this.updateComplete; // DOM reflects loading state before continuing
await this.api.post(this.data);
this.loading = false;
}
## Rules
- Use @property for anything a consumer sets from outside
- Use @state for anything that only the element itself sets
- NEVER use both @property and @state on the same field
- To update nested object fields: this.config = { ...this.config, key: newValue }
- To update array items: this.items = this.items.map(i => i.id === id ? updated : i)
The updated() lifecycle method is how Lit handles derived state. Claude will sometimes generate a set value(v) setter that calls this.processValue(v) and then this.requestUpdate(). The correct pattern is updated() reacting to changedProperties. The setter approach works but bypasses Lit's batching and makes the reactive cycle harder to follow.
lit-html template syntax
The html\`tagged template literal is the Lit render function. It is not JSX, it is notinnerHTML, and it is not a string. It is a TemplateResult` that Lit renders efficiently by remembering which parts of the template change between renders and updating only those.
Add a template section to CLAUDE.md that covers every binding type Claude needs to generate:
## Template binding reference
### Text interpolation
html`<p>${this.message}</p>`
### Attribute binding (string value)
html`<input type=${this.inputType}>`
### Property binding (non-string, bypasses attribute)
html`<my-chart .data=${this.chartData}>`
### Boolean attribute (present or absent)
html`<button ?disabled=${this.isDisabled}>Submit</button>`
### Event listener
html`<button @click=${this.handleClick}>Go</button>`
html`<input @input=${(e: Event) => this.onInput(e)}>`
### Conditional rendering
html`${this.show ? html`<p>Visible</p>` : nothing}`
### List rendering
html`<ul>${this.items.map(item => html`<li data-id=${item.id}>${item.label}</li>`)}</ul>`
### Nested component reference
html`<my-button label="Save" @my-button-click=${this.save}></my-button>`
### Ref directive (access DOM element without @query)
import { ref, createRef } from 'lit/directives/ref.js';
private inputRef = createRef<HTMLInputElement>();
render() {
return html`<input ${ref(this.inputRef)}>`;
}
firstUpdated() {
this.inputRef.value?.focus();
}
## Rules
- NEVER use html`` in a function and call the function inside render(), compose with ${this.renderPart()} returning html``
- NEVER use innerHTML anywhere, it bypasses Lit's update mechanism and is a XSS risk
- NEVER concatenate strings to build HTML, always compose html`` templates
- Import nothing from 'lit' when using conditional empty rendering
- Lists: always key by a stable id, not by index, to avoid unnecessary DOM churn
The .data=${this.chartData} property binding (dot prefix) is how you pass an object or array to a child element without going through attribute serialisation. Claude will use the bare data=${JSON.stringify(this.chartData)} form, which works but is fragile because JSON serialisation of complex objects loses type information and class instances. The property binding passes the object directly and avoids the problem.
Styling with static styles and adoptedStyleSheets
Lit compiles static styles = css\`into aCSSStyleSheetand applies it viaadoptedStyleSheetson the element's shadow root. This is faster than injecting a
More like this
Ready to upgrade your Claude Code setup?
Get Claudify