Stencil.js Guide

Quick Start

# Create a new project npm init stencil # Choose "component" starter, enter project name cd my-components npm install # Development (hot-reload) npm start # Production build npm run build # Run tests npm test

Stencil offers three project starters: component (component library), app (full application), and ionic-pwa (Ionic PWA app). Component library is the most common choice.

Component Basics

import { Component, Prop, State, Event, EventEmitter, h } from '@stencil/core'; @Component({ tag: 'my-counter', styleUrl: 'my-counter.css', shadow: true, }) export class MyCounter { @Prop() initial: number = 0; @Prop({ mutable: true, reflect: true }) value: number = 0; @State() history: number[] = []; @Event() valueChanged: EventEmitter<number>; componentWillLoad() { this.value = this.initial; } @Watch('value') watchValue(newVal: number) { this.history = [...this.history, newVal]; this.valueChanged.emit(newVal); } render() { return ( <Host> <button onClick={() => this.value--}>โˆ’</button> <span class="value">{this.value}</span> <button onClick={() => this.value++}>+</button> <p class="history">{this.history.join(' โ†’ ')}</p> </Host> ); } }

Key concepts: Stencil components compile to standard Custom Elements with zero runtime dependency. Written in TypeScript + JSX, but output standard browser code. The @Component tag must contain a hyphen (W3C spec requirement).

Decorators Reference

DecoratorPurposeExample
@ComponentDefine tag name, style file, and Shadow DOM mode@Component({ tag: 'my-cmp', shadow: true })
@Prop()Public property, settable externally, triggers re-render@Prop() name: string
@Prop({ mutable })Prop that can be mutated internally@Prop({ mutable: true }) open: boolean
@Prop({ reflect })Sync value back to HTML attribute (for CSS selectors)@Prop({ reflect: true }) color: string
@State()Internal reactive state, triggers re-render on change@State() items: string[] = []
@Watch()Callback when a Prop or State changes@Watch('value') onValueChange(n, o) {}
@Event()Declare a custom DOM event emitter@Event() myEvent: EventEmitter<T>
@Listen()Listen to DOM events (including window/document level)@Listen('resize', { target: 'window' })
@Method()Expose public async method, callable via JS@Method() async open() {}
@Element()Get reference to the host HTMLElement@Element() el: HTMLElement

Lifecycle Methods

connectedCallback
โ†’
componentWillLoad
โ†’
componentWillRender
โ†’
render()
โ†’
componentDidRender
โ†’
componentDidLoad
MethodWhen CalledAsync?Common Use
connectedCallbackElement inserted into DOMNoAdd event listeners
disconnectedCallbackElement removed from DOMNoCleanup (remove listeners, timers)
componentWillLoadBefore first render (once)YesFetch data, initialize state
componentDidLoadAfter first render (once)NoDOM manipulation, init 3rd-party libs
componentWillRenderBefore every renderYesCompute derived data
componentDidRenderAfter every renderNoPost-render DOM operations
componentShouldUpdateWhen Prop/State changesNoPerf: return false to skip render
componentWillUpdateBefore re-render due to changesYesPre-update preparation
componentDidUpdateAfter re-render due to changesNoPost-update operations
Note

componentWillLoad can return a Promise โ€” Stencil will wait for it to resolve before the first render, making it ideal for data fetching.

Props Deep Dive

@Prop() name: string; // immutable by default @Prop({ mutable: true }) open: boolean = false; // can be changed internally @Prop({ reflect: true }) color: string; // syncs to HTML attribute @Prop({ attribute: 'full-name' }) fullName: string; // custom attribute name // Supported types: string, number, boolean, Object, Array // Objects/Arrays pass by reference (use JSON attribute for HTML) // <my-cmp items='["a","b"]'></my-cmp>

Prop type mapping: In HTML, string/number/boolean are auto-converted. Objects and Arrays must be passed as JSON strings (HTML attributes) or via JavaScript properties. reflect: true enables CSS selectors like [color="red"] to work.

State

@State() items: string[] = []; @State() loading: boolean = false; // โŒ Wrong: mutating array in place won't trigger re-render this.items.push('new item'); // โœ… Correct: create a new reference this.items = [...this.items, 'new item']; // โœ… For objects: this.user = { ...this.user, name: 'new name' };
Important

Stencil uses reference comparison to detect changes. For Array/Object State, you must create a new reference (spread operator) to trigger re-renders. Direct push/property mutation won't be detected.

Event System

Emitting Events

@Event({ eventName: 'myCustomEvent', // optional: defaults to property name bubbles: true, // default: true composed: true, // crosses Shadow DOM boundary cancelable: true, }) myCustomEvent: EventEmitter<{ id: number; value: string }>; // Emit: this.myCustomEvent.emit({ id: 1, value: 'hello' });

Listening to Events

// Listen on the component itself @Listen('click') handleClick(ev: MouseEvent) { ... } // Listen on window @Listen('scroll', { target: 'window', passive: true }) handleScroll() { ... } // Listen on document @Listen('keydown', { target: 'document' }) handleKeydown(ev: KeyboardEvent) { ... } // In parent HTML/JS: document.querySelector('my-cmp').addEventListener('myCustomEvent', (e) => { console.log(e.detail); // { id: 1, value: 'hello' } });

Styling & Shadow DOM

shadow: true

Full encapsulation: external CSS cannot affect internals, internal CSS won't leak. Use CSS Custom Properties to pierce. Recommended for libraries.

scoped: true

Stencil auto-adds data attributes for scope isolation (like Vue scoped). No Shadow DOM, better compatibility but weaker encapsulation.

No encapsulation

No shadow/scoped: CSS is global. Suitable for app-level components, not recommended for shared libraries.

/* my-button.css */ :host { display: inline-block; /* style the component itself */ } :host([disabled]) { opacity: 0.5; /* style based on host attribute */ pointer-events: none; } ::slotted(span) { font-weight: bold; /* style slotted content */ } /* CSS Custom Properties for theming */ :host { --btn-bg: var(--primary-color, #6c63ff); --btn-radius: 8px; } button { background: var(--btn-bg); border-radius: var(--btn-radius); }

Slots (Content Projection)

// Component with named and default slots render() { return ( <Host> <header><slot name="header">Default Header</slot></header> <main><slot></slot></main> {/* default slot */} <footer><slot name="footer"></slot></footer> </Host> ); } <!-- Usage: --> <my-card> <h2 slot="header">Card Title</h2> <p>This goes into the default slot.</p> <button slot="footer">Action</button> </my-card>

Testing

Unit Tests (spec)

import { newSpecPage } from '@stencil/core/testing'; import { MyCounter } from './my-counter'; describe('my-counter', () => { it('renders with default value', async () => { const page = await newSpecPage({ components: [MyCounter], html: `<my-counter></my-counter>`, }); expect(page.root.shadowRoot.querySelector('.value').textContent) .toBe('0'); }); it('increments on click', async () => { const page = await newSpecPage({ components: [MyCounter], html: `<my-counter initial="5"></my-counter>`, }); const btn = page.root.shadowRoot.querySelectorAll('button')[1]; btn.click(); await page.waitForChanges(); expect(page.rootInstance.value).toBe(6); }); });

E2E Tests

import { newE2EPage } from '@stencil/core/testing'; it('should toggle open', async () => { const page = await newE2EPage(); await page.setContent('<my-dropdown></my-dropdown>'); const el = await page.find('my-dropdown'); const event = await el.spyOnEvent('opened'); await el.click(); expect(event).toHaveReceivedEvent(); });

Output Targets

TargetDescriptionUse Case
distnpm-publishable library with lazy loadingPublishing component libraries
dist-custom-elementsOne ES Module per component, no lazy loadingTree-shaking, bundler integration
wwwDev server + pre-rendered static siteDevelopment, static site generation
dist-hydrate-scriptNode.js rendering script (SSR)Server-side rendering
docs-readmeAuto-generate Markdown docsComponent API docs
docs-jsonJSON format API docsIntegrate with doc systems
// stencil.config.ts export const config: Config = { outputTargets: [ { type: 'dist', esmLoaderPath: '../loader' }, { type: 'dist-custom-elements' }, { type: 'docs-readme' }, { type: 'www', serviceWorker: null }, ], };

Config Reference

// stencil.config.ts import { Config } from '@stencil/core'; import { sass } from '@stencil/sass'; export const config: Config = { namespace: 'my-components', // library name globalStyle: 'src/global.css', // global stylesheet globalScript: 'src/global.ts', // runs once on load plugins: [sass()], // Sass/SCSS support taskQueue: 'async', // 'async' | 'congestionAsync' | 'immediate' extras: { experimentalImportInjection: true, }, testing: { browserArgs: ['--no-sandbox'], }, outputTargets: [ ... ], };

CLI Commands

CommandDescription
npm init stencilInteractive project creation
npm startStart dev server with hot-reload
npm run buildProduction build
npm testRun spec + e2e tests
npm run test.watchWatch mode testing
npm run generateInteractive component file generator
stencil build --docsBuild and generate docs
stencil build --watchWatch mode build

Best Practices

Component Naming

Tags must contain a hyphen and be lowercase. Use a consistent prefix: my-button, my-modal. Class names use PascalCase: MyButton.

Props Are Immutable by Default

Modifying a Prop internally without mutable: true triggers a warning. Prefer @State for internal data. Only use mutable props when you need two-way binding.

Keep render() Pure

render() should be a pure function โ€” return JSX only, no state mutations or side effects. Put side effects in lifecycle methods or event handlers.

Shadow DOM vs Scoped

Prefer shadow: true for npm-published libraries (full style isolation). Use scoped: true for app-internal components (better compatibility).

Use Host Element

Wrap render output with <Host> to set classes, attributes, and events on the host element itself.

Lazy vs Custom Elements

dist auto-lazy-loads components (best for large libraries). dist-custom-elements has no lazy loading but supports tree-shaking (best for few components or bundled apps).

FAQ

How is Stencil different from React/Vue/Angular?

Stencil is a compiler, not a framework. It compiles TypeScript + JSX into standard Web Components (Custom Elements + Shadow DOM) at build time, producing native browser code with zero runtime dependency. Generated components work in any framework or vanilla JS.

Can I use Stencil components in React/Vue/Angular?

Yes. Stencil components are standard Custom Elements and work directly in any framework. Stencil also offers framework wrappers via @stencil/react-output-target, @stencil/vue-output-target, etc. for a more native DX.

What is Stencil best for?

โ‘  Cross-framework UI component libraries (Ionic Framework is built with Stencil); โ‘ก Design systems; โ‘ข Shared components in micro-frontends; โ‘ฃ Projects needing dependency-free, lightweight Web Components.

Does Stencil support SSR?

Yes. The dist-hydrate-script output target enables pre-rendering in Node.js for SSR and static site generation.

How is Stencil's performance?

Stencil uses virtual DOM diffing, async render queuing, and lazy loading. Build output is tiny (no framework runtime). Only the components actually used on a page are loaded. Ionic 6+ (100+ UI components) is built entirely with Stencil and used in production at scale.

๐Ÿ’ฌ Comments