Home
Softono

Custom Elements Ts

Open source MIT TypeScript
62
Stars
10
Forks
0
Issues
7
Watchers
4 months
Last Commit

 About Custom Elements Ts

Create Native Web Components using Typescript

Platforms

Web Self-hosted

Languages

TypeScript

Need Help Installing Custom Elements Ts?

We provide expert installation service for this software. Our team will install, configure, and secure Custom Elements Ts on your server. plans start at just $30.

Custom Elements Ts

View on GitHub

custom-elements-ts

Coverage Status CI npm version License: MIT Ask DeepWiki

custom-elements-ts — Native Web Components, written in TypeScript

Author native Web Components in TypeScript with a small set of decorators (@CustomElement, @Prop, @State, @Watch, @Listen, @Dispatch, @Toggle) plus a tiny html / render() runtime. Zero dependencies. Framework-free.

npm install custom-elements-ts

Live demos: geocine.github.io/custom-elements-ts — counter, sprint board, install pill, and a live event log, all built with the library.

Table of contents

Quick start

There are two ways to author a component, and they compose freely:

  1. Plain HTML, imperative updates. Declare a template (or templateUrl) and update the DOM yourself in connectedCallback, @Watch() handlers, or @Listen() handlers. No render(). No reactive runtime. Use this when the DOM is mostly static — buttons, badges, panels, copy-to-clipboard pills, and so on.
  2. Reactive render() with the html helper. Define render() and the runtime re-renders for you on @Prop() / @State() / @Toggle() changes. Use this for stateful components like dashboards, counters, forms, and lists.

render() is optional — components without it pay zero runtime cost beyond the decorators themselves.

Plain HTML — no render() required

A small toast-firing "click to copy" pill, written entirely with a static template and imperative DOM. This is exactly the pattern used by <cts-message> on the showcase page:

import {
  CustomElement,
  Prop,
  Listen,
  Dispatch,
  DispatchEmitter,
} from 'custom-elements-ts';

@CustomElement({
  tag: 'cts-message',
  template: `
    <div class="row" role="button" tabindex="0">
      <span class="prompt">$</span>
      <code class="cmd"></code>
    </div>
  `,
  styleUrl: './message.element.scss',
})
export class MessageElement extends HTMLElement {
  @Prop() message!: string;

  // Bubbling, composed CustomEvent — any ancestor can listen for it
  // (e.g. a <cts-toast> at the document root).
  @Dispatch('cts:toast') toast!: DispatchEmitter;

  connectedCallback() {
    // Imperative DOM update — no render() needed.
    this.shadowRoot!.querySelector('.cmd')!.textContent = this.message;
  }

  @Listen('click')
  async handleClick() {
    await navigator.clipboard.writeText(this.message);
    this.toast.emit({
      bubbles: true,
      composed: true,
      detail: { title: 'Copied to clipboard', message: this.message },
    });
  }
}
<!-- Drop it anywhere — React, Vue, Svelte, plain HTML — it just works -->
<cts-message message="npm install custom-elements-ts"></cts-message>
<script src="https://raw.githubusercontent.com/geocine/custom-elements-ts/master/message.umd.js"></script>

You can also keep markup in its own file with templateUrl and styleUrl, exactly as you would with any other framework:

@CustomElement({
  tag: 'counter-element',
  templateUrl: './counter-element.html',
  styleUrl: './counter-element.scss',
})
export class CounterElement extends HTMLElement {
  // Wire up DOM manually in connectedCallback / @Watch / @Listen.
}

Reactive components with render()

Add a render() method that returns an html template literal and the runtime takes care of efficient DOM updates whenever any @Prop() / @State() / @Toggle() value changes:

import { CustomElement, State, html } from 'custom-elements-ts';

@CustomElement({ tag: 'cts-counter' })
export class CounterElement extends HTMLElement {
  @State() count = 0;

  render() {
    return html`<button @click=${this.increment}>Count: ${this.count}</button>`;
  }

  private increment() {
    this.count++;
  }
}

render() output is mounted into the shadow root by default. Pass shadow: false to render into the host element instead.

Template bindings

When you do opt into render(), the html helper supports the common binding forms used by render-based components:

html`<p>${this.label}</p>`;
html`<p>${() => this.label}</p>`;
html`<button @click=${this.handleClick}></button>`;
html`<input .value=${this.value} />`;
html`<div title=${this.title}></div>`;
html`<ul>
  ${this.items.map((item) => html`<li>${item.label}</li>`)}
</ul>`;

Attribute bindings remove the attribute when the value is false, null, or undefined. Event bindings replace old listeners when a render supplies a new handler and are cleaned up automatically when the rendered template is disposed.

Decorators

Decorator Target Parameters Description
@Prop() property - custom attribute/properties; reflects primitive values (string, number, boolean) to attributes
@State() property - private reactive state for render-based components; not reflected to attributes
@Toggle() property - boolean attribute/properties based on the presence of the attribute; also accepts "true" and "false"
@Dispatch() property (event?) declares a CustomEvent you can fire via the .emit method of its DispatchEmitter type. The event parameter sets the CustomEvent name
@Watch() method (property) runs the method when property changes
@Listen() method (event, selector?) listens for event on the host (or on selector inside the shadow tree)

@Prop()

import { CustomElement, Prop } from 'custom-elements-ts';

@CustomElement({
  tag: 'todo-list',
  ...
})
export class TodoList extends HTMLElement {
  @Prop() color: string;
  @Prop() list: TodoItem[];
}

Since color is a primitive type of string it can be accessed via attributes and properties:

const element = document.querySelector('todo-list');
// accessing value via attribute
const attrValue = element.getAttribute('color');
// setting value via attribute
element.setAttribute('color', 'red');

// accessing value via property
const propertyValue = element.color;
// setting via property
element.color = 'red';

list is a rich data type (objects or arrays) and functions/classes can only be accessed/set via property — they are not reflected as attributes:

// Functions and classes are not reflected to attributes
@Prop() onChange: (detail: any) => void;
@Prop() itemConstructor: { new(...args: any[]): any };

element.onChange = () => {};
// not reflected as attribute
console.log(element.getAttribute('on-change')); // null

class Foo {}
element.itemConstructor = Foo;
// not reflected as attribute
console.log(element.getAttribute('item-ctor')); // null

Render-based components update after a real @Prop() value change. Multiple prop and state changes inside the same synchronous turn are batched into one render.

@State()

import { CustomElement, State, Watch, html } from 'custom-elements-ts';

@CustomElement({ tag: 'profile-card' })
export class ProfileCard extends HTMLElement {
  @State() user = { name: 'Ada' };
  @State() items = [{ label: 'One' }];

  @Watch('user')
  userChanged(value: { old: unknown; new: unknown }) {
    console.log(value.new);
  }

  render() {
    return html`
      <strong>${this.user.name}</strong>
      <ul>
        ${this.items.map((item) => html`<li>${item.label}</li>`)}
      </ul>
    `;
  }
}

State is internal to the element: it is not reflected to attributes and is not included in observedAttributes. Plain objects and arrays assigned to state are deeply proxied, so nested mutations such as this.user.name = 'Grace', this.items.push(...), and this.items[0].label = 'Updated' schedule a render.

Only plain objects and arrays are proxied. Functions, class constructors, DOM nodes, Date, Map, Set, WeakMap, and WeakSet are left as-is — reassign those values to trigger a render.

@Toggle()

Toggle attributes work the same way as HTML boolean attributes as defined by W3C for the most part. We changed a few things to overcome confusion. Check the table below for reference:

Markup disabled Description
<c-input /> false Follows W3C standard
<c-input disabled/> true Follows W3C standard
<c-input disabled="true"/> true Follows W3C standard
<c-input disabled="asd"/> false false since asd does not evaluate to a valid boolean
<c-input disabled="false"/> false false since the boolean false converted to a string is "false"
<c-input disabled="true"/> true true since the boolean true converted to a string is "true"

@Dispatch()

Creating a custom event

import { CustomElement, Dispatch, DispatchEmitter } from 'custom-elements-ts';

...
export class TodoList extends HTMLElement {
  // Creating a CustomEvent
  // custom event name will be `on.change`
  @Dispatch() onChange: DispatchEmitter;

  // Creating a CustomEvent with custom name `ce.select`
  @Dispatch('ce.select') onSelect: DispatchEmitter;
}

Triggering the custom event from the example above:

  triggerOnChange() {
    // adding more data to the event object
    this.onChange.emit({ detail: 'event changed' });
    this.onSelect.emit({ detail: 'select triggered' });
  }

For events that need to cross the shadow boundary (e.g. so a parent or the document can listen) opt into bubbling and composed delivery on the emit() call:

this.onChange.emit({
  bubbles: true,
  composed: true,
  detail: { count: this.count },
});

@Watch()

import { CustomElement, Prop, Watch } from 'custom-elements-ts';

...
export class TodoList extends HTMLElement {
  @Prop() color: string;

  @Watch('color')
  colorChanged() {
    // trigger when color property color changes
    // either via property or attribute
  }
}

@Listen()

@Listen() takes an event and an optional selector. event is any valid JavaScript event. selector is anything that works with querySelector().

import { CustomElement, Listen } from 'custom-elements-ts';

...
export class TodoList extends HTMLElement {
  @Listen('click')
  elementClicked() {
    // triggers when the element is clicked
  }

  @Listen('click', 'a')
  anchorClicked() {
    // triggers when an `a` inside the element is clicked
  }
}

Project layout

src/                 # the library — decorators + html/render runtime
demos/
  counter/           # @State() + render() — single counter card
  todo-dashboard/    # composed elements: stats, filters, items, parent
  site/              # the showcase landing page that hosts every live demo
tests/               # vitest specs for the runtime + decorators
tools/               # build / start / bundle scripts

The site demo (demos/site) imports the counter and the todo-dashboard elements from sibling demo folders, so the showcase page on localhost:3000 runs the real components — not screenshots — and includes a <cts-event-log> panel that subscribes to their bubbling CustomEvents in real time.

Running the demos

npm start <element-name>
Element Highlights
site Showcase landing page (hero, code preview, live demos, OG graph)
counter @State() + @Watch() + @Dispatch() on a single card
todo-dashboard Parent / child composition with deeply proxied state
npm start site
npm start counter
npm start todo-dashboard

The dev server runs on http://localhost:3000 and live-reloads on TypeScript / SCSS / HTML changes.

Building

Building a demo

npm run build <element-name>

For a minified bundle:

npm run build -- <element-name> --prod

Building the library (publish artifacts)

Builds the library from src/index.ts into dist/ (UMD + ESM builds with typings):

npm run bundle