Creating Web Components
Now that you've built your first LARC application, it's time to master the art of creating robust, reusable Web Components. This chapter covers everything from basic component anatomy to advanced patterns like composition, slots, and performance optimization.
By the end of this chapter, you'll be able to build production-quality components that are maintainable, testable, and performant.
Anatomy of a LARC Component
!Figure 4.1: Component Lifecycle Flow
Figure 4.1: Component Lifecycle FlowLet's dissect a well-structured LARC component to understand its parts:
// Import dependencies
import { pan } from '@larcjs/core';
import { formatDate } from '../lib/utils.js';
/**
* A card component for displaying user information.
*
* @element user-card
*
* @attr {string} user-id - The ID of the user to display
* @attr {boolean} compact - Display in compact mode
*
* @fires user-selected - Dispatched when card is clicked
*
* @slot - Default slot for additional content
* @slot actions - Slot for action buttons
*/
class UserCard extends HTMLElement {
// 1. Define observed attributes
static get observedAttributes() {
return ['user-id', 'compact'];
}
// 2. Constructor - initialize instance
constructor() {
super();
// Attach shadow DOM
this.attachShadow({ mode: 'open' });
// Initialize private state
this._user = null;
this._loading = false;
this._error = null;
// Bind event handlers
this.handleClick = this.handleClick.bind(this);
}
// 3. Lifecycle: connected to DOM
connectedCallback() {
this.render();
// Load user data if ID is provided
const userId = this.getAttribute('user-id');
if (userId) {
this.loadUser(userId);
}
// Subscribe to PAN events
this.unsubscribe = pan.subscribe('user.updated', this.handleUserUpdate);
// Add event listeners
this.shadowRoot.addEventListener('click', this.handleClick);
}
// 4. Lifecycle: disconnected from DOM
disconnectedCallback() {
// Clean up subscriptions
if (this.unsubscribe) {
this.unsubscribe();
}
// Remove event listeners
this.shadowRoot.removeEventListener('click', this.handleClick);
}
// 5. Lifecycle: attributes changed
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
if (name === 'user-id' && newValue) {
this.loadUser(newValue);
} else if (name === 'compact') {
this.render();
}
}
// 6. Public properties with getters/setters
get user() {
return this._user;
}
set user(value) {
this._user = value;
this.render();
}
get loading() {
return this._loading;
}
// 7. Public methods
async loadUser(userId) {
this._loading = true;
this._error = null;
this.render();
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to load user');
this._user = await response.json();
this._loading = false;
this.render();
} catch (error) {
this._error = error.message;
this._loading = false;
this.render();
}
}
refresh() {
const userId = this.getAttribute('user-id');
if (userId) {
this.loadUser(userId);
}
}
// 8. Private methods
handleClick(event) {
if (!this._user) return;
this.dispatchEvent(new CustomEvent('user-selected', {
detail: { user: this._user },
bubbles: true,
composed: true
}));
}
handleUserUpdate = (data) => {
if (data.userId === this.getAttribute('user-id')) {
this._user = data.user;
this.render();
}
}
// 9. Render method
render() {
const compact = this.hasAttribute('compact');
if (this._loading) {
this.shadowRoot.innerHTML = this.renderLoading();
return;
}
if (this._error) {
this.shadowRoot.innerHTML = this.renderError();
return;
}
if (!this._user) {
this.shadowRoot.innerHTML = this.renderEmpty();
return;
}
this.shadowRoot.innerHTML = compact
? this.renderCompact()
: this.renderFull();
}
renderLoading() {
return `
<style>${this.styles()}</style>
<div class="card loading">
<div class="spinner"></div>
<p>Loading...</p>
</div>
`;
}
renderError() {
return `
<style>${this.styles()}</style>
<div class="card error">
<p class="error-message">${this._error}</p>
<button class="retry">Retry</button>
</div>
`;
}
renderEmpty() {
return `
<style>${this.styles()}</style>
<div class="card empty">
<p>No user data</p>
</div>
`;
}
renderCompact() {
return `
<style>${this.styles()}</style>
<div class="card compact">
<img src="${this._user.avatar}" alt="${this._user.name}">
<div class="info">
<h3>${this._user.name}</h3>
<slot name="actions"></slot>
</div>
</div>
`;
}
renderFull() {
return `
<style>${this.styles()}</style>
<div class="card">
<div class="header">
<img src="${this._user.avatar}" alt="${this._user.name}" class="avatar">
<div class="header-content">
<h2>${this._user.name}</h2>
<p class="email">${this._user.email}</p>
</div>
</div>
<div class="body">
<p class="bio">${this._user.bio || 'No bio available'}</p>
<div class="meta">
<span>Joined ${formatDate(this._user.createdAt)}</span>
</div>
<slot></slot>
</div>
<div class="footer">
<slot name="actions"></slot>
</div>
</div>
`;
}
// 10. Styles
styles() {
return `
:host {
display: block;
cursor: pointer;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 16px;
transition: box-shadow 0.2s;
}
.card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.header {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
h2 {
margin: 0;
font-size: 18px;
color: #333;
}
.email {
margin: 4px 0 0 0;
font-size: 14px;
color: #666;
}
.bio {
color: #444;
line-height: 1.5;
}
.meta {
font-size: 12px;
color: #999;
margin-top: 12px;
}
.loading, .error, .empty {
text-align: center;
padding: 40px 20px;
color: #666;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
color: #e53e3e;
}
.compact {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
}
.compact img {
width: 40px;
height: 40px;
border-radius: 50%;
}
.compact h3 {
margin: 0;
font-size: 14px;
}
`;
}
}
// 11. Register the custom element
customElements.define('user-card', UserCard);
// 12. Export for use in other modules
export default UserCard;
Component Structure Breakdown
1. Documentation:- JSDoc comments explain usage
- Attribute, property, event, and slot documentation
- Helps other developers understand the component
observedAttributesdefines which attributes triggerattributeChangedCallback- Keep this list minimal for performance
- Initialize instance variables
- Attach shadow DOM
- Bind methods (for event handlers)
- Don't access attributes or DOM here
connectedCallback: Setup when added to DOMdisconnectedCallback: Cleanup when removedattributeChangedCallback: Respond to attribute changes
- Use private fields (
_user) for internal state - Provide getters/setters for public API
- Setters can trigger re-renders
- Public methods for external use
- Private methods (conventionally start with
_or use#private fields) - Keep methods focused and single-purpose
- Separate render logic from state management
- Multiple render methods for different states
- Extract styles to a separate method
Shadow DOM Deep Dive
!Figure 4.2: Slots and Content Projection
Figure 4.2: Slots and Content ProjectionShadow DOM is one of the most powerful features of Web Components. It provides true encapsulation for both markup and styles.
Creating Shadow DOM
class MyComponent extends HTMLElement {
constructor() {
super();
// Create shadow root
this.attachShadow({ mode: 'open' });
// mode: 'open' - shadow root accessible via element.shadowRoot
// mode: 'closed' - shadow root not accessible (rarely used)
}
}
Shadow DOM vs Light DOM
<my-component>
<!-- This is Light DOM (regular DOM) -->
<p>Visible content</p>
</my-component>
<script>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
// This is Shadow DOM
this.shadowRoot.innerHTML = `
<div class="shadow-content">
<h2>Shadow DOM Content</h2>
<slot></slot>
</div>
`;
}
}
customElements.define('my-component', MyComponent);
</script>
Result:
- Light DOM (
) is projected into theVisible content
- Shadow DOM provides the structure and styling
- Styles in shadow DOM don't leak out
- Styles from light DOM don't leak in
Style Encapsulation
class StyledButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
/* These styles only affect this component */
button {
background: blue;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: darkblue;
}
</style>
<button><slot></slot></button>
`;
}
}
Key Points:
- Styles inside shadow DOM are scoped
- No conflicts with global styles
- No CSS class name collisions
- True component encapsulation
The :host Selector
Style the component itself:
:host {
display: block;
margin: 16px 0;
}
/* Style host when it has a class */
:host(.highlighted) {
border: 2px solid gold;
}
/* Style host when it has an attribute */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Style host in specific contexts */
:host-context(.dark-theme) {
background: #333;
color: white;
}
CSS Custom Properties (Variables)
CSS variables pierce the shadow DOM boundary:
// Component defines and uses variables
class ThemedCard extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
background: var(--card-bg, white);
color: var(--card-text, black);
border: 1px solid var(--card-border, #ddd);
border-radius: var(--card-radius, 8px);
padding: var(--card-padding, 16px);
}
</style>
<slot></slot>
`;
}
}
Usage:
<style>
/* Override component variables from outside */
themed-card {
--card-bg: #f0f0f0;
--card-text: #333;
--card-border: #ccc;
--card-radius: 12px;
}
themed-card.dark {
--card-bg: #333;
--card-text: #fff;
--card-border: #555;
}
</style>
<themed-card>Normal theme</themed-card>
<themed-card class="dark">Dark theme</themed-card>
This pattern allows theming while maintaining encapsulation.
Parts and ::part()
Expose specific shadow DOM elements for styling:
class FancyButton extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
button { /* default styles */ }
.icon { /* icon styles */ }
</style>
<button part="button">
<span part="icon" class="icon">โ</span>
<slot></slot>
</button>
`;
}
}
Style from outside:
fancy-button::part(button) {
background: linear-gradient(135deg, #667eea, #764ba2);
}
fancy-button::part(icon) {
color: gold;
}
This gives consumers more control while maintaining encapsulation.
Attributes and Properties
Understanding the difference between attributes and properties is crucial for component design.
Attributes vs Properties
Attributes:- HTML attributes (
) - Always strings
- Visible in HTML
- Trigger
attributeChangedCallback
- JavaScript properties (
element.foo = 123) - Any type (string, number, object, etc.)
- Not visible in HTML
- Direct access, no callback
Reflecting Properties to Attributes
class ToggleButton extends HTMLElement {
static get observedAttributes() {
return ['checked'];
}
constructor() {
super();
this._checked = false;
}
// Property getter
get checked() {
return this._checked;
}
// Property setter - reflects to attribute
set checked(value) {
const isChecked = Boolean(value);
if (isChecked) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
}
// Attribute changed - updates property
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'checked') {
this._checked = newValue !== null;
this.render();
}
}
render() {
this.innerHTML = `
<button class="${this._checked ? 'checked' : ''}">
${this._checked ? 'โ' : 'โ'}
</button>
`;
}
}
Usage:
<!-- Set via attribute -->
<toggle-button checked></toggle-button>
<script>
const toggle = document.querySelector('toggle-button');
// Set via property
toggle.checked = true;
// Get property
console.log(toggle.checked); // true
// Check attribute
console.log(toggle.hasAttribute('checked')); // true
</script>
When to Use Each
Use Attributes for:- Simple configuration (strings, numbers, booleans)
- Values that should be visible in HTML
- Initial configuration from HTML
- Values that need to work with CSS selectors
- Complex data (objects, arrays, functions)
- Data that changes frequently
- Large data that shouldn't serialize to HTML
- Callback functions
Type Conversion
Attributes are always strings, so convert appropriately:
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'count') {
this._count = Number(newValue) || 0;
} else if (name === 'enabled') {
this._enabled = newValue !== null; // Boolean attribute
} else if (name === 'options') {
try {
this._options = JSON.parse(newValue);
} catch {
this._options = {};
}
}
}
Boolean Attributes
Follow HTML conventions:
// Boolean attribute: presence = true, absence = false
if (this.hasAttribute('disabled')) {
// Is disabled
}
// Set boolean attribute
this.setAttribute('disabled', ''); // value doesn't matter
// Remove boolean attribute
this.removeAttribute('disabled');
Component Styling
!Figure 4.3: CSS Encapsulation with Shadow DOM
Figure 4.3: CSS Encapsulation with Shadow DOMInternal Styles
Most styles should be in shadow DOM:
styles() {
return `
:host {
display: block;
}
.container {
padding: 16px;
}
/* All your component styles */
`;
}
External Stylesheets
For larger components, link external styles:
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="/styles/components/user-card.css">
<div class="user-card">
<!-- content -->
</div>
`;
}
Adoptable Stylesheets
Share styles between component instances:
// Create shared stylesheet once
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
.card {
padding: 16px;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
`);
class CardComponent extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
// Adopt shared stylesheet (very fast)
this.shadowRoot.adoptedStyleSheets = [sheet];
this.shadowRoot.innerHTML = `
<div class="card">
<slot></slot>
</div>
`;
}
}
Benefits:
- Styles parsed once, shared across instances
- Better performance with many components
- Modify shared styles dynamically
Theming Strategies
Strategy 1: CSS Custom Propertiesclass ThemedComponent extends HTMLElement {
styles() {
return `
:host {
--primary-color: var(--app-primary, #667eea);
--background: var(--app-bg, white);
--text: var(--app-text, #333);
}
.content {
background: var(--background);
color: var(--text);
}
button {
background: var(--primary-color);
}
`;
}
}
Strategy 2: Class-Based Themes
class ThemeAwareComponent extends HTMLElement {
connectedCallback() {
// Observe theme changes on documentElement
const observer = new MutationObserver(() => {
this.updateTheme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
});
this.updateTheme();
}
updateTheme() {
const theme = document.documentElement.dataset.theme || 'light';
this.setAttribute('theme', theme);
}
styles() {
return `
:host([theme="light"]) {
background: white;
color: black;
}
:host([theme="dark"]) {
background: #333;
color: white;
}
`;
}
}
Strategy 3: PAN-Based Themes
import { pan } from '@larcjs/core';
class PanThemedComponent extends HTMLElement {
connectedCallback() {
this.unsubscribe = pan.subscribe('app.theme.changed', ({ theme }) => {
this.applyTheme(theme);
});
// Request current theme
pan.request('app.theme.get').then(theme => {
this.applyTheme(theme);
});
}
applyTheme(theme) {
this.setAttribute('data-theme', theme);
}
}
Lifecycle Methods (Advanced Patterns)
Deferred Rendering
Wait for dependencies before rendering:
class DataDisplay extends HTMLElement {
async connectedCallback() {
// Wait for dependencies to load
await customElements.whenDefined('loading-spinner');
await customElements.whenDefined('error-message');
// Now render
this.render();
}
}
Preventing Memory Leaks
class WebSocketComponent extends HTMLElement {
connectedCallback() {
this.ws = new WebSocket('wss://api.example.com');
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
disconnectedCallback() {
// Clean up WebSocket connection
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
Handling Rapid Reconnection
Components can be disconnected and reconnected quickly:
class RobustComponent extends HTMLElement {
connectedCallback() {
// Might be called multiple times
// Use a guard to prevent duplicate setup
if (this._initialized) {
return;
}
this._initialized = true;
this.setup();
}
disconnectedCallback() {
// Use setTimeout to debounce
this._cleanupTimer = setTimeout(() => {
this.cleanup();
this._initialized = false;
}, 100);
}
connectedCallback() {
// Cancel cleanup if reconnected quickly
if (this._cleanupTimer) {
clearTimeout(this._cleanupTimer);
this._cleanupTimer = null;
}
if (this._initialized) {
return;
}
this._initialized = true;
this.setup();
}
}
Testing Components
Unit Testing
Test components in isolation:
// tests/user-card.test.js
import { expect } from '@open-wc/testing';
import '../user-card.js';
describe('UserCard', () => {
let element;
beforeEach(() => {
element = document.createElement('user-card');
document.body.appendChild(element);
});
afterEach(() => {
element.remove();
});
it('renders empty state by default', () => {
const emptyText = element.shadowRoot.querySelector('.empty');
expect(emptyText).to.exist;
});
it('loads user when user-id attribute is set', async () => {
// Mock fetch
global.fetch = async () => ({
ok: true,
json: async () => ({ id: 1, name: 'John Doe', email: 'john@example.com' })
});
element.setAttribute('user-id', '1');
// Wait for async operations
await new Promise(resolve => setTimeout(resolve, 100));
const name = element.shadowRoot.querySelector('h2');
expect(name.textContent).to.equal('John Doe');
});
it('handles loading state', async () => {
element.setAttribute('user-id', '1');
const spinner = element.shadowRoot.querySelector('.spinner');
expect(spinner).to.exist;
});
it('dispatches user-selected event on click', async () => {
element._user = { id: 1, name: 'John' };
element.render();
let eventData = null;
element.addEventListener('user-selected', (e) => {
eventData = e.detail;
});
element.shadowRoot.querySelector('.card').click();
expect(eventData).to.deep.equal({ user: { id: 1, name: 'John' } });
});
});
Integration Testing
Test components working together:
// tests/counter-integration.test.js
describe('Counter Integration', () => {
beforeEach(() => {
document.body.innerHTML = `
<counter-display></counter-display>
<counter-controls></counter-controls>
`;
});
it('updates display when controls are clicked', async () => {
const display = document.querySelector('counter-display');
const controls = document.querySelector('counter-controls');
const incrementBtn = controls.shadowRoot.querySelector('#increment');
incrementBtn.click();
await new Promise(resolve => setTimeout(resolve, 50));
const displayValue = display.shadowRoot.querySelector('.display').textContent;
expect(displayValue).to.equal('1');
});
});
Visual Regression Testing
Catch visual bugs:
// tests/visual.test.js
import puppeteer from 'puppeteer';
import pixelmatch from 'pixelmatch';
describe('Visual Regression', () => {
let browser, page;
beforeAll(async () => {
browser = await puppeteer.launch();
page = await browser.newPage();
});
afterAll(async () => {
await browser.close();
});
it('user-card matches snapshot', async () => {
await page.goto('http://localhost:3000/tests/user-card.html');
const screenshot = await page.screenshot({ fullPage: true });
const baseline = fs.readFileSync('tests/snapshots/user-card.png');
const diff = pixelmatch(screenshot, baseline, null, 800, 600, {
threshold: 0.1
});
expect(diff).to.be.lessThan(100); // Allow small differences
});
});
Summary
This chapter covered:
- Component Anatomy: Structure, lifecycle, and organization
- Shadow DOM: Encapsulation, slots, and styling
- Attributes vs Properties: When to use each and how to reflect them
- Component Styling: Internal styles, theming, and CSS custom properties
- Lifecycle Patterns: Memory management and robust connection handling
- Testing: Unit, integration, and visual regression testing
Best Practices
disconnectedCallback:host and CSS custom properties for theming
Further Reading
For complete Web Components reference:- Building with LARC Chapter 2: Core Concepts - Web Components architecture and lifecycle
- Building with LARC Chapters 17-21: Component Reference - Complete API documentation
- Building with LARC Chapter 13: Testing Strategies - Component testing patterns