DEV Community

Cover image for Write concise Custom Elements for React, Vue, Svelte, Solid, etc. Write once, use everywhere, type checked!
Joe Pea
Joe Pea

Posted on

Write concise Custom Elements for React, Vue, Svelte, Solid, etc. Write once, use everywhere, type checked!

I'm proud to release @lume/element v0.17.0.

This release adds support for Solid.js memos and effects for Custom Elements via decorator syntax.

Concisely write custom HTML elements, type checked in React, Vue, Svelte, Solid.js, React, Stencil, and more.

Here's a glance at what elements authored with @lume/element look like in JavaScript:

import {Element, element, numberAttribute, eventAttribute} from '@lume/element'
import {signal, memo, effect, startEffects, stopEffects} from 'classy-solid'

@element
class FurryDog extends Element {  
  @numberAttribute years = 7
  @eventAttribute onwoof = null

  @memo get dogYears() {
    return this.years * 7
  }

  @effect #log() {
    console.log('dog years:', this.dogYears)
    this.dispatchEvent(new Event('woof')) // bark any time age changes
  }

  template = () => <div>dog years: {this.dogYears}</div>

  css = `:host {color: cornflowerblue}`
}

const dog = document.createElement('furry-dog')
document.body.append(dog) // initially logs "dog years: 49"

dog.years = 8 // logs "dog years: 56"

// ...later, effects cleaned up when removed from DOM...
dog.remove()

// ...later, effects re-started if element reconnected...
document.body.append(dog)
Enter fullscreen mode Exit fullscreen mode

Use the custom element in HTML, or declarative framework templates.

HTML:

<furry-dog years="5" id="dog" onwoof="console.log('woof!')"></furry-dog>
<script>
  dog.years = 6
</script>
Enter fullscreen mode Exit fullscreen mode

React, Preact:

const [age, setAge] = useState(3)
return <furry-dog years={age} onwoof={() => console.log('woof!')}/>
Enter fullscreen mode Exit fullscreen mode

Solid.js JSX:

const [age, setAge] = createSignal(2)
return <furry-dog years={age()} on:woof={() => console.log('woof!')} />
Enter fullscreen mode Exit fullscreen mode

Vue:

<script setup>
  const age = ref(5)
</script>
<template>
  <furry-dog :years="age" @woof="console.log('woof!')" />
</template>
Enter fullscreen mode Exit fullscreen mode

Svelte:

<script>
  let age = $state(4);
</script>
<furry-dog years={age} onwoof={() => console.log('woof!')} />
Enter fullscreen mode Exit fullscreen mode

Type checking is available for all the above frameworks, including in the templates of the elements defined with @lume/element (templates are powered by (@ryansolid's) Solid.js). See the docs on how to connect your element definition to the type systems of each framework, and the various examples.

GitHub logo lume / element

Fast and simple custom elements.

@lume/element

Easily and concisely write Custom Elements with simple templates and reactivity.

Use the custom elements on their own in plain HTML or vanilla JavaScript, or in Vue, Svelte, Solid.js, Stencil.js, React, and Preact, with full type checking autocompletion, and intellisense in all the template systems of those frameworks, in any IDE that supports TypeScript such as VS Code.

Write your elements once, then use them in any app, with a complete developer experience no matter which base component system your app uses.

npm install @lume/element

💡Tip:

If you are new to Custom Elements, first learn about the basics of Custom Element APIs available natively in browsers. Lume Element simplifies the creation of Custom Elements compared to writing them with vanilla APIs, but sometimes vanilla APIs are all that is needed.

Live demos

  • Lume 3D HTML (The landing page, all of Lume's 3D elements, and the live code editors…

Top comments (3)

Collapse
 
leob profile image
leob

So how does this "magic" work - I understand these are Web Components (I had never heard about Custom Elements, but I understand they're a kind of Web Components), and you then provide framework "wrappers" around them for the various frontend frameworks?

Sounds a bit similar to what Ionic did when they "ported" their components from Angular to React and Vue - they used Web Components to pull this off ...

Collapse
 
trusktr profile image
Joe Pea

I understand these are Web Components (I had never heard about Custom Elements

The browser API is window.customElements. There's no API with "web components" in its naming, it's more like a term for various built-in APIs that you can put together.

I like "custom elements", or even "custom HTML elements", more than "web components" because everything else is already a web component: React components, Vue components, Solid.js components, Svelte components, etc, they're all web components! I've even seen the non-custom-element components called "web components" at a big org (and they were not referring to native custom elements, but were distinguishing from other components such as "android components" or "iOS components"). From that perspective, "custom elements" is much nicer because it aligns very well with "built-in elements", "HTML elements", or just "elements" (built-in or not), and I always teach them that way. "Web components" is too overloaded, and possibly even confusing for people who aren't deep enough into web yet.

you then provide framework "wrappers" around them for the various frontend frameworks?

No framework wrappers, that'd be too much work!

@lume/element provides type helpers to attach type definitions into the various frameworks. Write a custom element class definition, then connect its types into chosen framework, and it will be those elements being used directly rather than wrappers.

For example, after writing a custom element, it's type definition can be hooked into React by using the ReactElementAttributes helper:

import {Element, element, attribute, numberAttribute, type EventListener} from '@lume/element' 

export type SuperHeroAttributes = 'name' | 'age'

export const tagname = 'super-hero'

@element(tagname)
export class SuperHero extends Element {
  @attribute name = 'Batman'
  @numberAttribute age = 50
  @eventAttribute ontotherescue: EventListener<ToTheRescue> | null = null

  @effect log() {
    console.log('hero info:', this.name, this.age) // logs any time name or age change
  }

  // ... dispatches "totherescue" events based on some logic ...

  template = () => <p>Name: {this.name}, Age: {this.age}</p>
}

// Native DOM events
export class ToTheRescue extends Event {
  // ...
}

// Declare the types for standard DOM APIs, no matter the user's framework:
declare global {
  interface HTMLElementTagNameMap { [tagname]: SuperHero }
}

document.querySelector('super-hero') // return type is `SuperHero | null`
Enter fullscreen mode Exit fullscreen mode

Then we can put the definition for React consumers in a separate file that React users can import (we wouldn't want to define React types in the same file as the class, or a user of Vue/Svelte/etc will get type errors):

import {SuperHero, SuperHeroAttributes} from './super-hero.js'
import type {ReactElementAttributes} from '@lume/element/dist/framework-types/react.js'

// Hook the element's type definition into React.
declare module 'react' {
  namespace JSX {
    interface IntrinsicElements { [tagname]: ReactElementAttributes<SuperHero , SuperHeroAttributes> }
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, a React user can use the element with type checking:

import 'super-lib/super-hero.js'
import type {} 'super-lib/super-hero.react.d.ts' // React users opt into React types

function MyComp() {
  return <div>
    <super-hero name={someString} age={someNumber} ontotherescue={event => {
      console.log(event) // `event` is inferred as a `ToTheRescue` instance
    }}></super-hero>
  </div>
}
Enter fullscreen mode Exit fullscreen mode

An index file that imports all react type defs for all elements in a custom element lib makes it easy for React users to import a single entry one time in their app entry point, and get type definitions for all elements of the custom element library, f.e. import type {} 'super-lib/index.react.d.ts'.

There is not any compromise in usage either, the experience in any framework is complete without sacrificing any features of the custom element (at least when written with @lume/element). All @lume/element props can accept JS values or attribute strings (no matter what differing frameworks choose to pass in), and the event types are tested in all major frameworks, f.e. ontotherescue prop types in React/Preact/Solid.js/Svelte, @totherescue in Vue, and even el.ontotherescue = fn or el.setAttribute('ontotherescue', '...js code...') with native DOM APIs, so interoperability is maximal. The lume/element repo has an example for each framework.

There is more info on type definitions in the link I gave on how to connect your element definition to the type systems of each framework.

I still want to add support for Custom Elements Manifest, to extend types into Lit and other Custom Element frameworks with arbitrary template syntaxes in a more idiomatic way geared for compatibility across custom element libraries (and all the non-custom-element libraries too) and IDEs in other languages beyond TypeScript, as Custom Elements Manifest is the format the community is conventionalizing for this.

Collapse
 
leob profile image
leob • Edited

Thanks, makes sense, learned something ... "Web components" is just the term that I knew, but I agree it's pretty vague and generic - "Custom elements" seems more clear and precise!