Let’s dive into Stencil.js, a powerful and user-friendly tool designed for building Web Components. Web Components are a browser-native standard that allows you to create reusable components usable across frameworks. Stencil.js enhances Web Components with modern features like TypeScript, JSX, and virtual DOM, offering a React-like development experience while producing pure Web Components with high performance and excellent compatibility.
What is Stencil.js?
Stencil.js is an open-source tool developed by the Ionic team to simplify Web Component creation. Web Components are a set of browser standards (Custom Elements, Shadow DOM, HTML Templates, ES Modules) that let you define custom HTML tags with encapsulated styles and logic. Stencil.js builds on these standards, adding a modern development experience with features like:
- TypeScript Support: Static typing for safer code.
- JSX Syntax: Write components like React, with intuitive templates.
- Virtual DOM: Efficient updates, with performance comparable to React/Vue.
- Lazy Loading: Components load on-demand, reducing initial bundle size.
- Cross-Framework Compatibility: Outputs Web Components usable in React, Vue, Angular, or plain HTML.
Stencil.js compiles to standard JavaScript, with a tiny ~1KB runtime, making it extremely lightweight. We’ll start with basic components and progress to complex scenarios.
Environment Setup
To use Stencil.js, set up your environment with Node.js (18.x recommended) and the Stencil CLI.
Install the Stencil CLI:
npm install -g @stencil/core
Verify installation:
stencil --version
Create a project:
npm init stencil
Select the component option and name the project stencil-demo. The generated structure is:
stencil-demo/
├── src/
│ ├── components/
│ │ ├── my-component/
│ │ │ ├── my-component.tsx
│ │ │ ├── my-component.css
│ ├── components.d.ts
├── stencil.config.ts
├── package.json
Run npm start and visit localhost:3333 to see the default component page. The src/components directory is for components, and stencil.config.ts is the configuration file.
First Stencil Component
Let’s create a simple component displaying a name and a counter.
Update src/components/my-component/my-component.tsx:
import { Component, Prop, State, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
@Prop() name: string = 'Guest';
@State() count: number = 0;
render() {
return (
<div class="container">
<h1>Hello, {this.name}!</h1>
<p>Count: {this.count}</p>
<button onClick={() => this.count++}>Increment</button>
</div>
);
}
}
Update src/components/my-component/my-component.css:
.container {
padding: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
h1 {
color: #007bff;
}
button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
cursor: pointer;
}
button:hover {
background: #0056b3;
}
Update src/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Stencil Demo</title>
</head>
<body>
<my-component name="Alice"></my-component>
</body>
</html>
Run npm start. The page displays “Hello, Alice!” and a counter that increments on button clicks. Code breakdown:
-
@Componentdefines a Web Component with a custom tag (tag), enables Shadow DOM (shadow: true) for style encapsulation. -
@Propdeclares external properties (name), similar to React props. -
@Statemanages internal state (count), triggering re-renders on changes. -
renderreturns JSX, withhas the virtual DOM rendering function.
Component Properties and Events
Stencil components support properties (Props) and custom events for external interaction. Create a component with an input and event.
Update src/components/my-component/my-component.tsx:
import { Component, Prop, State, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'my-component',
styleUrl: 'my-component.css',
shadow: true,
})
export class MyComponent {
@Prop() name: string = 'Guest';
@State() inputValue: string = '';
@Event() nameChanged: EventEmitter<string>;
handleInput(e: Event) {
this.inputValue = (e.target as HTMLInputElement).value;
this.nameChanged.emit(this.inputValue);
}
render() {
return (
<div class="container">
<h1>Hello, {this.name}!</h1>
<input type="text" value={this.inputValue} onInput={e => this.handleInput(e)} placeholder="Enter name" />
</div>
);
}
}
Update src/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Stencil Demo</title>
</head>
<body>
<my-component name="Alice"></my-component>
<script>
const component = document.querySelector('my-component');
component.addEventListener('nameChanged', e => {
console.log('Name changed:', e.detail);
});
</script>
</body>
</html>
Enter a name, and the console logs Name changed: <input value>. The @Event decorator defines the nameChanged event, and emit sends data, which external code listens for via addEventListener.
Component Lifecycle
Stencil provides lifecycle methods similar to React. Create a component to show a loading state.
Create src/components/loading-component/loading-component.tsx:
import { Component, State, h } from '@stencil/core';
@Component({
tag: 'loading-component',
styleUrl: 'loading-component.css',
shadow: true,
})
export class LoadingComponent {
@State() data: string[] = [];
@State() isLoading: boolean = true;
async componentWillLoad() {
this.isLoading = true;
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
const todos = await response.json();
this.data = todos.slice(0, 5).map(todo => todo.title);
this.isLoading = false;
}
render() {
return (
<div class="container">
<h1>Todos</h1>
{this.isLoading ? (
<p>Loading...</p>
) : (
<ul>
{this.data.map(title => (
<li>{title}</li>
))}
</ul>
)}
</div>
);
}
}
Create src/components/loading-component/loading-component.css:
.container {
padding: 20px;
border: 1px solid #ccc;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 5px 0;
}
Update src/index.html:
<loading-component></loading-component>
Run the app. It shows “Loading...” initially, then displays five todo titles. The componentWillLoad lifecycle method runs before the component loads, ideal for async data fetching.
Using in a React Project
Stencil components are standard Web Components, usable directly in React. Create a React project:
npx create-react-app react-stencil
cd react-stencil
Build the Stencil components:
cd ../stencil-demo
npm run build
The dist directory contains the Web Components. Copy dist to react-stencil/public:
cp -r dist ../react-stencil/public/stencil
Update react-stencil/src/App.js:
import './App.css';
function App() {
return (
<div style={{ padding: 20 }}>
<h1>React with Stencil</h1>
<my-component name="React User"></my-component>
</div>
);
}
export default App;
Update react-stencil/public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>React Stencil</title>
<script type="module" src="/stencil/stencil-demo/stencil-demo.esm.js"></script>
<script nomodule src="/stencil/stencil-demo/stencil-demo.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
Run npm start. The page displays “Hello, React User!”. The esm.js is for modern browsers with ES Modules, while js ensures compatibility with older browsers.
Event Handling
Update App.js:
import { useEffect } from 'react';
import './App.css';
function App() {
useEffect(() => {
const component = document.querySelector('my-component');
component.addEventListener('nameChanged', e => {
console.log('From React:', e.detail);
});
}, []);
return (
<div style={{ padding: 20 }}>
<h1>React with Stencil</h1>
<my-component name="React User"></my-component>
</div>
);
}
export default App;
Enter a name, and the console logs the event data. React listens to Stencil component events via DOM events.
Using in a Vue Project
Stencil components work seamlessly in Vue. Create a Vue project:
vue create vue-stencil
cd vue-stencil
Copy stencil-demo/dist to vue-stencil/public/stencil. Update public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>Vue Stencil</title>
<script type="module" src="/stencil/stencil-demo/stencil-demo.esm.js"></script>
<script nomodule src="/stencil/stencil-demo/stencil-demo.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
Update src/App.vue:
<template>
<div style="padding: 20px;">
<h1>Vue with Stencil</h1>
<my-component name="Vue User"></my-component>
</div>
</template>
<script>
export default {
mounted() {
this.$el.querySelector('my-component').addEventListener('nameChanged', e => {
console.log('From Vue:', e.detail);
});
}
};
</script>
Run npm run serve. The page shows “Hello, Vue User!”, and entering a name triggers events. Vue listens via DOM events, making component reuse effortless.
Complex Component: Form and List
Create a more complex component with a form and dynamic list.
Create src/components/task-manager/task-manager.tsx:
import { Component, State, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'task-manager',
styleUrl: 'task-manager.css',
shadow: true,
})
export class TaskManager {
@State() tasks: { id: number; title: string; completed: boolean }[] = [];
@State() newTask: string = '';
@Event() taskAdded: EventEmitter<string>;
handleInput(e: Event) {
this.newTask = (e.target as HTMLInputElement).value;
}
async handleSubmit(e: Event) {
e.preventDefault();
if (!this.newTask) return;
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
body: JSON.stringify({ title: this.newTask, completed: false }),
headers: { 'Content-Type': 'application/json' },
});
const task = await response.json();
this.tasks = [...this.tasks, { id: task.id, title: this.newTask, completed: false }];
this.taskAdded.emit(this.newTask);
this.newTask = '';
}
toggleTask(id: number) {
this.tasks = this.tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
);
}
render() {
return (
<div class="container">
<h1>Task Manager</h1>
<form onSubmit={e => this.handleSubmit(e)}>
<input type="text" value={this.newTask} onInput={e => this.handleInput(e)} placeholder="New task" />
<button type="submit">Add Task</button>
</form>
<ul>
{this.tasks.map(task => (
<li key={task.id} class={task.completed ? 'completed' : ''}>
<input type="checkbox" checked={task.completed} onChange={() => this.toggleTask(task.id)} />
{task.title}
</li>
))}
</ul>
</div>
);
}
}
Create src/components/task-manager/task-manager.css:
.container {
padding: 20px;
border: 1px solid #ccc;
}
form {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"] {
padding: 8px;
width: 200px;
}
button {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
}
ul {
list-style: none;
padding: 0;
}
li {
margin: 5px 0;
}
.completed {
text-decoration: line-through;
color: #888;
}
Update src/index.html:
<task-manager></task-manager>
Run the app. Enter tasks, click to add, and the list updates. Checkboxes toggle task completion. The fetch simulates an API, and the taskAdded event notifies external listeners.
Lazy Loading and Distribution
Stencil supports lazy loading by default, with components loaded on-demand. Update stencil.config.ts:
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'stencil-demo',
outputTargets: [
{
type: 'dist',
esmLoaderPath: '../loader',
},
{
type: 'www',
serviceWorker: null,
},
],
};
Run npm run build. The dist directory generates lazy-loaded modules. Update react-stencil/public/index.html:
<script type="module">
import { defineCustomElements } from '/stencil/stencil-demo/loader/index.js';
defineCustomElements();
</script>
The loader loads components on-demand. The Network panel shows task-manager’s JavaScript loads only when used.
Performance Testing
Test Stencil components with Lighthouse. Run npm run build and npx serve dist:
- Traditional React Components: Bundle ~100KB, FCP ~0.8s.
- Stencil Web Components: Bundle ~10KB (runtime + components), FCP ~0.5s.
Stencil’s tiny runtime and lazy loading result in faster initial loads, with Web Components ensuring broad compatibility.
Real-World Scenario: Component Library
Create a component library with a button, card, and modal.
Create src/components/button/button.tsx:
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'app-button',
styleUrl: 'button.css',
shadow: true,
})
export class AppButton {
@Prop() type: 'primary' | 'secondary' = 'primary';
render() {
return (
<button class={this.type}>
<slot />
</button>
);
}
}
Create src/components/button/button.css:
button {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.primary {
background: #007bff;
color: white;
}
.secondary {
background: #6c757d;
color: white;
}
Create src/components/card/card.tsx:
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'app-card',
styleUrl: 'card.css',
shadow: true,
})
export class AppCard {
@Prop() title: string;
render() {
return (
<div class="card">
<h2>{this.title}</h2>
<slot />
</div>
);
}
}
Create src/components/card/card.css:
.card {
padding: 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
Create src/components/modal/modal.tsx:
import { Component, Prop, State, h } from '@stencil/core';
@Component({
tag: 'app-modal',
styleUrl: 'modal.css',
shadow: true,
})
export class AppModal {
@Prop() open: boolean = false;
@State() isOpen: boolean = false;
componentWillLoad() {
this.isOpen = this.open;
}
render() {
if (!this.isOpen) return null;
return (
<div class="modal">
<div class="content">
<slot />
<app-button type="secondary" onClick={() => (this.isOpen = false)}>
Close
</app-button>
</div>
</div>
);
}
}
Create src/components/modal/modal.css:
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.content {
background: white;
padding: 20px;
border-radius: 4px;
}
Update src/index.html:
<app-card title="Product">
<p>Price: $999</p>
<app-button type="primary">Buy Now</app-button>
<app-modal open>
<p>Confirm purchase?</p>
</app-modal>
</app-card>
Run the app to display a card, button, and modal. The component library is reusable and works across frameworks.
Conclusion
Stencil.js makes Web Component development simple and efficient, combining TypeScript, JSX, and virtual DOM to produce standard components. The examples demonstrated:
- Basic components, properties, and events.
- Lifecycle methods and async data fetching.
- Reuse in React and Vue.
- Complex form and list components.
- Lazy loading and component library development.
Run these examples, experiment with cross-framework usage and lazy loading, and experience Stencil.js’s power!
Top comments (0)