Skip to main content
Dev Notes

Web Component & Stencil.js

Dev Notes Disclaimer: Each artile in the Dev Notes section of my webiste may or may not be unfinished work. I don't always have time to write a full post. If you see something that looks like a half-baked idea, it probably is! If you have any questions or suggestions, feel free to reach out.

Topics to cover:

  • What is Stencil? (compiler, JSX syntax, props, state, lifecycle)
  • How Shadow DOM works and what encapsulation means
  • Slots and @Prop, @State, @Event, @Method

Output: A simple <fancy-button> or <my-modal> component with props, styles, and events

What Is Stencil.js?

Stencil is a compiler, not a runtime framework. You write components using a familiar syntax (it looks like JSX/React), and it compiles your code into standards-based Web Components.

Key benefits for design systems:

  • Output is native Web Components, usable in any framework
  • Built-in support for encapsulation, slots, props, and state
  • Super small footprint
  • Built-in TypeScript and lazy loading

Basic Stencil Component Example

Here’s what a simple Stencil component looks like:

// src/components/my-button/my-button.tsx

import { Component, Prop, h } from '@stencil/core';

@Component({
  tag: 'my-button',
  styleUrl: 'my-button.scss',
  shadow: true, // Enables Shadow DOM
})
export class MyButton {
  @Prop() label: string;

  render() {
    return <button>{this.label}</button>;
  }
}

Usage in HTML

Stencil builds this into a standard Web Component:

<my-button label="Click me"></my-button>

Stencil will auto-generate the proper JS to register this tag with the browser.

What To Remember

  • Stencil compiles to native Web Components
  • Each component is self-contained and framework-agnostic
  • Props are passed via attributes, and internal state uses @State()
  • Shadow DOM encapsulates both DOM and styles
  • Components are tree-shakable, lazy-loaded by default

Lifecycle Hooks

Stencil offers several lifecycle methods — here are the ones you’ll use most:

componentWillLoad() {
  console.log('Component is about to be rendered');
}

componentDidLoad() {
  console.log('Component has rendered and is now in the DOM');
}

Slots (Content Projection)

Stencil supports the standard Web Component <slot> element, which allows consumers to inject content into your component.

Example: Slotted Button

@Component({
  tag: 'fancy-card',
  styleUrl: 'fancy-card.css',
  shadow: true,
})
export class FancyCard {
  render() {
    return (
      <div class="card">
        <slot name="header"></slot>
        <div class="content">
          <slot></slot> {/* default slot */}
        </div>
      </div>
    );
  }
}

Consumer usage:

<fancy-card>
  <h2 slot="header">Card Header</h2>
  <p>This is the card content.</p>
</fancy-card>

This works similarly to Vue slots or React props.children.

Custom Events (Component Communication)

You can use @Event() to emit custom DOM events from your Web Component. Consumers (even in React or Angular) can listen to them.

Example: Button with click event

import { Component, h, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'fancy-button',
  shadow: true,
})
export class FancyButton {
  @Event() buttonClicked: EventEmitter<void>;

  handleClick() {
    this.buttonClicked.emit();
  }

  render() {
    return <button onClick={() => this.handleClick()}>Click Me</button>;
  }
}

Listening in plain HTML:

<fancy-button onbuttonClicked="alert('clicked!')"></fancy-button>

🔎 React/Angular note: Event names are lowercase in Stencil but often need to be camelCased in React (e.g. onButtonClicked), and explicitly bound using addEventListener.

Example Alert Banner Component

// file: src/components/alert-banner/alert-banner.tsx

import { Component, h, Prop, Event, EventEmitter } from '@stencil/core';

@Component({
  tag: 'alert-banner',
  styleUrl: 'alert-banner.css',
  shadow: true,
})
export class AlertBanner {
  @Prop() isVisible: boolean = true;
  @Event() dismissed: EventEmitter<void>;

  componentDidLoad() {
    // Just demoing one of the lifecycle hooks
    console.log('AlertBanner mounted');
  }

  handleDismiss() {
    this.dismissed.emit();
  }

  render() {
    if (!this.isVisible) return null;

    return (
      <div class="alert">
        <slot></slot>
        <button class="close-btn" onClick={() => this.handleDismiss()}>
          &times;
        </button>
      </div>
    );
  }
}

Alert CSS

/* file: alert-banner.css */

.alert {
  background-color: #ffeeba;
  color: #856404;
  padding: 1rem;
  border: 1px solid #ffeeba;
  border-radius: 4px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.close-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
}

Usage in HTML

Visible

<!-- Visible -->
<alert-banner is-visible="true" ondismissed="console.log('dismissed')">
  Hello again! I’m still here.
</alert-banner>

<!-- Hidden -->
 <alert-banner is-visible="false">
  You won’t see me unless someone sets isVisible = true.
</alert-banner>

Simulate Alert Usage in POJS

<alert-banner id="notice" is-visible="true">
  This banner can be closed by the user.
</alert-banner>

<script>
  const banner = document.getElementById('notice');

  banner.addEventListener('dismissed', () => {
    // Remove the attribute or set it to "false"
    banner.setAttribute('is-visible', 'false');
  });
</script>

Important: Stencil treats @Prop() values as strings when passed via HTML attributes, so we must use "false" as a string.

Alert Host

We'll create a new component: <alert-host>, which renders the <alert-banner>, and responds to its dismissed event.

Behavior

  • When <alert-host> mounts, it shows an alert
  • When <alert-banner> emits a dismissed event, the parent hides it

Alert Host Component

// file: alert-host.tsx

import { Component, h, State } from '@stencil/core';

@Component({
  tag: 'alert-host',
  shadow: true,
})
export class AlertHost {
  @State() showBanner: boolean = true;

  handleDismiss = () => {
    this.showBanner = false;
  };

  render() {
    return (
      <div>
        {this.showBanner && (
          <alert-banner
            is-visible={true}
            onDismissed={this.handleDismiss}
          >
            This is a parent-controlled alert!
          </alert-banner>
        )}
      </div>
    );
  }
}

Key Concepts:

  • @State() is reactive — when showBanner changes, the component re-renders
  • We use onDismissed to handle the child’s custom event
  • This is similar to how a React parent listens for child events and updates local state