Developing a Web Components Library (Stencil)
Stencil is a component compiler from Ionic. Written components compile into native Web Components with optional support for Angular, React, Vue through auto-generated wrappers. Stencil is not a runtime library, but a build tool: the resulting bundle contains only native APIs plus a minimal polyfill layer.
Key difference from Lit: Stencil generates framework-specific packages. If the library should work as native in Angular (with two-way binding, forms), as React components (with typed props) — Stencil does this out of the box.
Installation and initialization
npm init stencil@latest
# Select: component library
cd my-library
npm install
Project structure:
src/
├── components/
│ ├── ui-button/
│ │ ├── ui-button.tsx ← component
│ │ ├── ui-button.css ← styles
│ │ ├── ui-button.e2e.ts ← E2E tests
│ │ └── ui-button.spec.ts ← unit tests
│ └── ui-input/
├── utils/
├── index.ts
└── index.html
stencil.config.ts
Stencil component
Stencil uses TSX (like React) and decorators for component markup:
import {
Component,
Host,
h,
Prop,
State,
Event,
EventEmitter,
Method,
Watch,
Element,
Listen,
} from '@stencil/core'
@Component({
tag: 'ui-button',
styleUrl: 'ui-button.css',
shadow: true, // enable Shadow DOM
// scoped: true, // instead of Shadow DOM — scoped CSS (no slots, but works with forms)
})
export class UiButton {
// Reference to host element
@Element() el!: HTMLElement
// Props — public properties/attributes
@Prop() variant: 'primary' | 'secondary' | 'ghost' = 'primary'
@Prop() size: 'sm' | 'md' | 'lg' = 'md'
@Prop({ reflect: true }) disabled = false
@Prop({ mutable: true }) loading = false // mutable — component can change
// Internal state
@State() private focused = false
// Events
@Event({ eventName: 'uiClick', bubbles: true, composed: true })
uiClick!: EventEmitter<{ nativeEvent: MouseEvent }>
// Watch — reaction to prop/state change
@Watch('disabled')
onDisabledChange(newVal: boolean) {
this.el.setAttribute('aria-disabled', String(newVal))
}
// Listen — listen to events (on host or document)
@Listen('focus', { target: 'window' })
onWindowFocus(e: FocusEvent) {
// ...
}
// Public method — called from JS
@Method()
async focusButton() {
this.el.shadowRoot?.querySelector('button')?.focus()
}
private handleClick = (e: MouseEvent) => {
if (this.disabled || this.loading) return
this.uiClick.emit({ nativeEvent: e })
}
render() {
return (
<Host
class={{
'is-disabled': this.disabled,
'is-loading': this.loading,
}}
aria-disabled={this.disabled ? 'true' : null}
>
<button
type="button"
disabled={this.disabled || this.loading}
class={`btn btn--${this.variant} btn--${this.size}`}
onClick={this.handleClick}
onFocus={() => (this.focused = true)}
onBlur={() => (this.focused = false)}
>
{this.loading && <span class="spinner" aria-hidden="true"></span>}
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
</Host>
)
}
}
/* ui-button.css */
:host {
display: inline-flex;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
cursor: pointer;
font-family: inherit;
font-weight: 600;
border-radius: var(--ui-radius, 8px);
transition: background 0.15s, transform 0.1s;
}
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn:active:not(:disabled) { transform: scale(0.97); }
.btn--primary { background: var(--ui-primary, #7000ff); color: #fff; }
.btn--secondary { background: transparent; border: 1.5px solid var(--ui-primary, #7000ff); color: var(--ui-primary, #7000ff); }
.btn--ghost { background: transparent; color: var(--ui-primary, #7000ff); }
.btn--sm { padding: 6px 14px; font-size: 13px; }
.btn--md { padding: 10px 22px; font-size: 15px; }
.btn--lg { padding: 14px 30px; font-size: 17px; }
Stencil Config: multiple output targets
Main capability of Stencil — compile one component into multiple formats:
// stencil.config.ts
import { Config } from '@stencil/core'
import { angularOutputTarget } from '@stencil/angular-output-target'
import { reactOutputTarget } from '@stencil/react-output-target'
import { vueOutputTarget } from '@stencil/vue-output-target'
export const config: Config = {
namespace: 'my-ui',
outputTargets: [
// 1. Native Web Components
{
type: 'dist',
esmLoaderPath: '../loader',
},
// 2. dist-custom-elements — tree-shakeable
{
type: 'dist-custom-elements',
customElementsExportBehavior: 'auto-define-custom-elements',
externalRuntime: false,
},
// 3. React wrappers (autogeneration)
reactOutputTarget({
componentCorePackage: 'my-ui-core',
proxiesFile: '../my-ui-react/src/components.ts',
includeDefineCustomElements: true,
}),
// 4. Vue wrappers
vueOutputTarget({
componentCorePackage: 'my-ui-core',
proxiesFile: '../my-ui-vue/src/components.ts',
}),
// 5. Angular wrappers with NgModule
angularOutputTarget({
componentCorePackage: 'my-ui-core',
outputType: 'standalone', // or 'component'
directivesProxyFile: '../my-ui-angular/src/directives/proxies.ts',
}),
// 6. Documentation
{ type: 'docs-readme' },
{ type: 'docs-json', file: './dist/docs.json' },
// 7. Custom Elements Manifest for IDE hints
{ type: 'docs-vscode', file: './dist/vscode.html-data.json' },
],
testing: {
browserHeadless: 'new',
},
}
Auto-generated React wrappers
After npm run build in my-ui-react/src/components.ts:
// Generated by Stencil — don't edit manually
import { createReactComponent } from './react-component-lib'
import { defineCustomElements } from 'my-ui-core/loader'
defineCustomElements()
export const UiButton = /*@__PURE__*/ createReactComponent<
JSX.UiButton,
HTMLUiButtonElement
>('ui-button')
export const UiInput = /*@__PURE__*/ createReactComponent<
JSX.UiInput,
HTMLUiInputElement
>('ui-input')
Usage in React:
import { UiButton, UiInput } from 'my-ui-react'
function Form() {
const handleClick = (e: CustomEvent<{ nativeEvent: MouseEvent }>) => {
console.log('clicked', e.detail)
}
return (
<form>
<UiInput label="Email" type="email" required />
<UiButton
variant="primary"
onUiClick={handleClick} // typed event handler
loading={false}
>
Submit
</UiButton>
</form>
)
}
Testing
Stencil includes Stencil Testing Utils on top of Jest + Puppeteer:
// ui-button.spec.ts — unit tests
import { newSpecPage } from '@stencil/core/testing'
import { UiButton } from './ui-button'
describe('ui-button', () => {
it('renders with default props', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button>Click me</ui-button>',
})
expect(page.root).toEqualHtml(`
<ui-button>
<mock:shadow-root>
<button class="btn btn--primary btn--md" type="button">
<slot name="icon-start"></slot>
<slot></slot>
<slot name="icon-end"></slot>
</button>
</mock:shadow-root>
Click me
</ui-button>
`)
})
it('disables button when disabled prop is set', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button disabled></ui-button>',
})
const button = page.root?.shadowRoot?.querySelector('button')
expect(button?.disabled).toBe(true)
})
it('emits uiClick event', async () => {
const page = await newSpecPage({
components: [UiButton],
html: '<ui-button></ui-button>',
})
const clickSpy = jest.fn()
page.root?.addEventListener('uiClick', clickSpy)
page.root?.shadowRoot?.querySelector('button')?.click()
expect(clickSpy).toHaveBeenCalled()
})
})
Monorepo: multi-package structure
packages/
├── core/ ← Stencil components (my-ui-core)
├── react/ ← React wrappers (my-ui-react)
├── vue/ ← Vue wrappers (my-ui-vue)
├── angular/ ← Angular wrappers (my-ui-angular)
└── docs/ ← Storybook
Timeline
5–8 components with dist and React output — 2–3 weeks. Full library with Angular/Vue wrappers, E2E tests, Storybook, CD pipeline and npm publication — 6–10 weeks.







