Web Components provide framework-agnostic UI building blocks that work in React, Vue, Angular, or vanilla JavaScript. 2025 brings mature tooling and widespread adoption. At ZIRA Software, Web Components power our cross-framework design system.
Web Components Standards
Web Components APIs
├── Custom Elements
│ └── Define new HTML tags
├── Shadow DOM
│ └── Encapsulated styles and markup
├── HTML Templates
│ └── Reusable markup templates
└── ES Modules
└── Standard module loading
Vanilla Web Component
// components/my-button.ts
class MyButton extends HTMLElement {
static observedAttributes = ['variant', 'disabled'];
private _shadowRoot: ShadowRoot;
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
this._shadowRoot.querySelector('button')?.addEventListener('click', this._handleClick);
}
disconnectedCallback() {
this._shadowRoot.querySelector('button')?.removeEventListener('click', this._handleClick);
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (oldValue !== newValue) {
this.render();
}
}
private _handleClick = (e: Event) => {
if (this.hasAttribute('disabled')) {
e.preventDefault();
return;
}
this.dispatchEvent(new CustomEvent('my-click', {
bubbles: true,
composed: true,
}));
};
private render() {
const variant = this.getAttribute('variant') || 'primary';
const disabled = this.hasAttribute('disabled');
this._shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
button.primary {
background: #0ea5e9;
color: white;
border: none;
}
button.primary:hover:not(:disabled) {
background: #0284c7;
}
button.secondary {
background: #f1f5f9;
color: #1e293b;
border: 1px solid #e2e8f0;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<button class="${variant}" ${disabled ? 'disabled' : ''}>
<slot></slot>
</button>
`;
}
}
customElements.define('my-button', MyButton);
Lit Framework
// components/lit-button.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@customElement('lit-button')
export class LitButton extends LitElement {
static styles = css`
:host {
display: inline-block;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.primary {
background: var(--color-primary, #0ea5e9);
color: white;
}
.primary:hover:not(:disabled) {
background: var(--color-primary-dark, #0284c7);
}
.secondary {
background: var(--color-secondary, #f1f5f9);
color: var(--color-text, #1e293b);
border: 1px solid var(--color-border, #e2e8f0);
}
.loading {
position: relative;
color: transparent;
}
.spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
:host([disabled]) button {
opacity: 0.5;
cursor: not-allowed;
}
`;
@property({ type: String }) variant: 'primary' | 'secondary' = 'primary';
@property({ type: Boolean }) disabled = false;
@property({ type: Boolean }) loading = false;
render() {
const classes = {
[this.variant]: true,
loading: this.loading,
};
return html`
<button
class=${classMap(classes)}
?disabled=${this.disabled || this.loading}
@click=${this._handleClick}
>
<slot></slot>
${this.loading ? html`<span class="spinner">⏳</span>` : ''}
</button>
`;
}
private _handleClick(e: Event) {
if (this.disabled || this.loading) {
e.preventDefault();
return;
}
this.dispatchEvent(new CustomEvent('lit-click', {
bubbles: true,
composed: true,
}));
}
}
Using in React
// React wrapper for type safety
import { createComponent } from '@lit/react';
import { LitButton } from '@mylib/web-components';
export const Button = createComponent({
tagName: 'lit-button',
elementClass: LitButton,
react: React,
events: {
onClick: 'lit-click',
},
});
// Usage
function App() {
return (
<Button
variant="primary"
onClick={()=> console.log('clicked')}
>
Click Me
</Button>
);
}
Using in Vue
<!-- Vue - Works directly -->
<template>
<lit-button
:variant="variant"
:disabled="isLoading"
@lit-click="handleClick"
>
{{ label }}
</lit-button>
</template>
<script setup lang="ts">
import '@mylib/web-components';
const props = defineProps<{
label: string;
variant?: 'primary' | 'secondary';
}>();
const emit = defineEmits<{
(e: 'click'): void;
}>();
const handleClick = () => emit('click');
</script>
Slot Patterns
// components/card.ts
@customElement('my-card')
export class MyCard extends LitElement {
static styles = css`
:host {
display: block;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.header {
padding: 1rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.content {
padding: 1rem;
}
.footer {
padding: 1rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
}
::slotted([slot="header"]) {
margin: 0;
font-weight: 600;
}
`;
render() {
return html`
<div class="header">
<slot name="header"></slot>
</div>
<div class="content">
<slot></slot>
</div>
<div class="footer">
<slot name="footer"></slot>
</div>
`;
}
}
<!-- Usage -->
<my-card>
<h3 slot="header">Card Title</h3>
<p>Card content goes here...</p>
<div slot="footer">
<lit-button>Action</lit-button>
</div>
</my-card>
Testing Web Components
// button.test.ts
import { fixture, html, expect } from '@open-wc/testing';
import './lit-button';
describe('LitButton', () => {
it('renders with default props', async () => {
const el = await fixture(html`<lit-button>Click</lit-button>`);
expect(el).to.exist;
expect(el.shadowRoot?.querySelector('button')).to.have.class('primary');
});
it('emits lit-click event', async () => {
const el = await fixture(html`<lit-button>Click</lit-button>`);
let clicked = false;
el.addEventListener('lit-click', () => { clicked = true; });
el.shadowRoot?.querySelector('button')?.click();
expect(clicked).to.be.true;
});
it('does not emit when disabled', async () => {
const el = await fixture(html`<lit-button disabled>Click</lit-button>`);
let clicked = false;
el.addEventListener('lit-click', () => { clicked = true; });
el.shadowRoot?.querySelector('button')?.click();
expect(clicked).to.be.false;
});
});
Conclusion
Web Components enable truly framework-agnostic UI development. Lit provides excellent DX while maintaining interoperability. Use Web Components for design systems that need to work across React, Vue, Angular, and vanilla JS.
Building cross-framework components? Contact ZIRA Software for Web Components development.