DEV Community

Tianya School
Tianya School

Posted on

Stencil.js A Modern Tool for Building Web Components

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
Enter fullscreen mode Exit fullscreen mode

Verify installation:

stencil --version
Enter fullscreen mode Exit fullscreen mode

Create a project:

npm init stencil
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Update src/index.html:

<!DOCTYPE html>
<html>
<head>
  <title>Stencil Demo</title>
</head>
<body>
  <my-component name="Alice"></my-component>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Run npm start. The page displays “Hello, Alice!” and a counter that increments on button clicks. Code breakdown:

  • @Component defines a Web Component with a custom tag (tag), enables Shadow DOM (shadow: true) for style encapsulation.
  • @Prop declares external properties (name), similar to React props.
  • @State manages internal state (count), triggering re-renders on changes.
  • render returns JSX, with h as 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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Update src/index.html:

<loading-component></loading-component>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build the Stencil components:

cd ../stencil-demo
npm run build
Enter fullscreen mode Exit fullscreen mode

The dist directory contains the Web Components. Copy dist to react-stencil/public:

cp -r dist ../react-stencil/public/stencil
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Update src/index.html:

<task-manager></task-manager>
Enter fullscreen mode Exit fullscreen mode

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,
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Create src/components/card/card.css:

.card {
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)