Developing a Web Components Library (Lit)
Lit is a Google library for creating Web Components. It weighs ~5kb gzipped and builds on top of native APIs (Custom Elements, Shadow DOM, Templates), adding reactive properties, declarative templates via tagged template literals, and automatic DOM updates.
Choosing Lit over "bare" Custom Elements is justified when there are more than 3–5 components: Lit removes boilerplate code for manual DOM updates, reactive attributes, and lifecycle management.
Installation and project structure
npm create vite@latest my-components -- --template lit-ts
cd my-components
npm install
Or in an existing project:
npm install lit
Typical library structure:
src/
├── components/
│ ├── button/
│ │ ├── button.ts
│ │ ├── button.styles.ts
│ │ └── button.test.ts
│ ├── modal/
│ └── tooltip/
├── styles/
│ └── tokens.css ← CSS custom properties
├── index.ts ← re-export all components
└── types.ts
Basic component
import { LitElement, html, css, PropertyValues } from 'lit'
import { customElement, property, state, query } from 'lit/decorators.js'
import { classMap } from 'lit/directives/class-map.js'
import { ifDefined } from 'lit/directives/if-defined.js'
@customElement('ui-button')
export class UiButton extends LitElement {
// Reactive properties — changes trigger re-render
@property({ type: String })
variant: 'primary' | 'secondary' | 'ghost' = 'primary'
@property({ type: String })
size: 'sm' | 'md' | 'lg' = 'md'
@property({ type: Boolean, reflect: true })
disabled = false
@property({ type: Boolean })
loading = false
@property({ type: String })
type: 'button' | 'submit' | 'reset' = 'button'
// Internal state — not an attribute, not a public property
@state()
private _focused = false
// Access to DOM element inside shadow
@query('button')
private _button!: HTMLButtonElement
static styles = css`
:host {
display: inline-flex;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
border-radius: var(--ui-radius, 8px);
transition: background 0.15s, opacity 0.15s, transform 0.1s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:active:not(:disabled) {
transform: scale(0.97);
}
/* Variants */
:host([variant="primary"]) button,
button.variant--primary {
background: var(--ui-color-primary, #7000ff);
color: #fff;
}
:host([variant="secondary"]) button,
button.variant--secondary {
background: transparent;
border: 1.5px solid var(--ui-color-primary, #7000ff);
color: var(--ui-color-primary, #7000ff);
}
:host([variant="ghost"]) button,
button.variant--ghost {
background: transparent;
color: var(--ui-color-primary, #7000ff);
}
/* Sizes */
.size--sm { padding: 6px 14px; font-size: 13px; }
.size--md { padding: 10px 22px; font-size: 15px; }
.size--lg { padding: 14px 30px; font-size: 17px; }
/* Loading spinner */
.spinner {
width: 1em;
height: 1em;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`
render() {
const classes = classMap({
[`variant--${this.variant}`]: true,
[`size--${this.size}`]: true,
'is-loading': this.loading,
})
return html`
<button
class=${classes}
type=${this.type}
?disabled=${this.disabled || this.loading}
aria-disabled=${this.disabled || this.loading}
@focus=${() => { this._focused = true }}
@blur=${() => { this._focused = false }}
>
${this.loading ? html`<span class="spinner" aria-hidden="true"></span>` : ''}
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
`
}
// Lifecycle: after first render
protected firstUpdated() {
this._button.addEventListener('click', this._handleClick)
}
// Lifecycle: after each update
protected updated(changedProps: PropertyValues) {
if (changedProps.has('disabled')) {
this.setAttribute('aria-disabled', String(this.disabled))
}
}
private _handleClick = (e: Event) => {
if (this.disabled || this.loading) {
e.preventDefault()
e.stopPropagation()
return
}
// Custom event that bubbles
this.dispatchEvent(new CustomEvent('ui-click', {
bubbles: true,
composed: true,
detail: { originalEvent: e },
}))
}
// Public method (called from outside)
focus() {
this._button?.focus()
}
disconnectedCallback() {
super.disconnectedCallback()
this._button?.removeEventListener('click', this._handleClick)
}
}
declare global {
interface HTMLElementTagNameMap {
'ui-button': UiButton
}
}
Reactive properties in detail
@customElement('ui-tabs')
export class UiTabs extends LitElement {
// reflect: true — syncs JS property with HTML attribute
@property({ type: Number, reflect: true })
activeIndex = 0
// converter — custom string attribute to type transformation
@property({
converter: {
fromAttribute: (value: string | null) =>
value ? value.split(',').map(Number) : [],
toAttribute: (value: number[]) => value.join(','),
}
})
selectedIds: number[] = []
// hasChanged — skip unnecessary updates
@property({
hasChanged: (newVal: object, oldVal: object) =>
JSON.stringify(newVal) !== JSON.stringify(oldVal),
})
config: Record<string, unknown> = {}
}
Directives in templates
import { repeat } from 'lit/directives/repeat.js'
import { cache } from 'lit/directives/cache.js'
import { asyncReplace } from 'lit/directives/async-replace.js'
import { ref } from 'lit/directives/ref.js'
render() {
return html`
<!-- repeat with key for efficient diff -->
<ul>
${repeat(
this.items,
(item) => item.id, // key
(item) => html`<li>${item.name}</li>`
)}
</ul>
<!-- cache — doesn't destroy DOM on switch -->
${cache(this.showDetails
? html`<details-panel></details-panel>`
: html`<summary-view></summary-view>`
)}
<!-- ref — access to DOM element -->
<canvas ${ref(this._canvasRef)}></canvas>
`
}
Controllers (Reactive Controllers)
Reusable logic independent of the component:
// controllers/mouse-controller.ts
import { ReactiveController, ReactiveControllerHost } from 'lit'
export class MouseController implements ReactiveController {
host: ReactiveControllerHost
x = 0
y = 0
constructor(host: ReactiveControllerHost) {
this.host = host
host.addController(this)
}
hostConnected() {
window.addEventListener('mousemove', this._onMouseMove)
}
hostDisconnected() {
window.removeEventListener('mousemove', this._onMouseMove)
}
private _onMouseMove = (e: MouseEvent) => {
this.x = e.clientX
this.y = e.clientY
this.host.requestUpdate() // triggers component re-render
}
}
// Using in a component
@customElement('cursor-tracker')
class CursorTracker extends LitElement {
private mouse = new MouseController(this)
render() {
return html`
<p>Cursor: ${this.mouse.x}, ${this.mouse.y}</p>
`
}
}
Building a library for publication
// package.json
{
"name": "@myorg/ui-components",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./button": {
"import": "./dist/components/button/button.js",
"types": "./dist/components/button/button.d.ts"
}
},
"files": ["dist"],
"customElements": "custom-elements.json"
}
// vite.config.ts for library
export default defineConfig({
build: {
lib: {
entry: 'src/index.ts',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['lit'], // don't bundle Lit — peer dependency
},
},
})
Timeline
3–5 components with Lit, decorators and basic build — 1 week. Full library of 10–15 components with controllers, types, storybook documentation, tests (Playwright) and npm publication — 3–5 weeks.







