npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@hot-page/functional-element

v0.0.2

Published

Simple define helper for functional web components

Readme

Functional Element

Introduction

Web Components are the best way to share small pieces of functionality for web pages, especially when used in sites with static HTML. You get all the benefits of a component based architecture like React without having to swallow the whole workflow. Use declarative, static HTML but also get the goodness of bits of interactivity.

Making simple web components is kinda sucky though -- there's a lot of boilerplate, you have to know about JavaScript classes and keep a lot of stuff in your head about lifecycle and callbacks to make it work right.

This project is an attempt to simplify the process of building one-off custom elements. With a simple helper function you can write components with reactivity using a functional structure.

The best part is this builds on new web standards that make all this super easy. The only two dependencies are things that (hopefuly) will be standardized in the web platform sooner rather than later.

Goals

  • Static pages with sprinkles of interactivity - Perfect for adding dynamic elements to mostly-static HTML sites
  • Minimal boilerplate - Write components quickly without class ceremony
  • Standards-based - Built on Web Components, works everywhere

Non-Goals

  • Building full applications - Use React, Vue, or Svelte for SPAs
  • Complex state management - This isn't Redux or Zustand
  • Build tooling - No bundlers, no compilation (though you can use them if you want)
  • Large state trees - Keep it simple with local component state

Quick Start

  1. Load @hot-page/functional-element from a CDN or install it with NPM.
  2. Import one of the define functions: shadowElement or lightElement. Shadow element renders in shadow DOM, and light element renders in normal DOM.
  3. Define your functional component by providing a function.
  4. Use the state argument to create new reactive properties
  5. Return a template that will be re-rendered

Create a new element in plain JavaScript:

import { shadowElement, html, state } from 'https://esm.sh/@hot-page/functional-element'

// Call the define function with our component
shadowElement(function HueSlider() {
  const value = state(0)
  const callCount = state(0)

  function onInput(event) {
    // N.B. this will only call render one time even though we set two signals
    value.set(event.target.value)
    callCount.set(callCount.get() + 1)
  }

  // Return a render function
  return () => {
    // Update a property on the element
    this.hue = value.get()
    // Return an HTML template with reactive properties in it.
    return html`
      <style>
        :host {
          display: block;
          padding: 16px;
          background: hsl(${value.get()}, 100%, 90%);
        }
      </style>
      <input type=range min=0 max=255 .value=${value.get()} @input=${onInput}>
      <p>Hue: ${value.get()}</p>
      <p>Update count: ${callCount.get()}</p>
    `
  }
})

Use the element in your HTML:

<hue-slider></hue-slider>

That's it!

Let's talk about what's happening here.

  1. You are calling a function shadowElement. We can call that the "define function".
  2. You are passing a single argument, which is also a function. Let's call that the "functional component".
  3. That in turn returns a function, which we can call the "render function".

I told you this was functional!

It's important to understand when these functions will run.

  1. The define function runs once for every custom element you want to create.
  2. The functional component will run every time one of your elements on the page is created.
  3. The render function runs when the reactive properties change and the component's markup will be updated

The component receives a context object with:

  • effect - Register side effects with cleanup (see Lifecycle & Cleanup below)
  • internals - Access to ElementInternals API (see Using Element Internals below)

Rendering in Shadow or Light DOM

This package provides two define exports:

  • lightElement which will render the template into the element's children.
  • shadowElement which will render the template into a Shadow DOM.

You can also use the define() function directly if you prefer:

import { define, html, state } from '@hot-page/functional-element'

define({
  attributes: ['color', 'size'],
  useShadow: true  // or false for light DOM
  component: function MyElement({ effect }) {
    const count = state(0)
    return () => html`<p>${count.get()}</p>`
  },
})

Lifecycle & Cleanup

Use the effect function to register side effects that need cleanup:

shadowElement(function oneSecondCounter({ effect }) {
  const count = state(0)

  effect(() => {
    // Setup: runs when element is connected to DOM
    const interval = setInterval(() => {
      count.set(count.get() + 1)
    }, 1000)

    // Cleanup: runs when element is disconnected
    return () => clearInterval(interval)
  })

  effect(() => {
    // You can register multiple effects
    const handleResize = () => console.log('resized')
    window.addEventListener('resize', handleResize)
    
    return () => window.removeEventListener('resize', handleResize)
  })

  return () => html`<p>Count: ${count.get()}</p>`
})

When effects run:

  • Setup functions run when the element connects to the DOM
  • Cleanup functions run when the element disconnects from the DOM
  • If an element is moved in the DOM, cleanup runs, then setup runs again

When you need effects:

  • Global event listeners (window, document)
  • Timers (setInterval, setTimeout)
  • Observers (IntersectionObserver, MutationObserver)
  • External subscriptions (WebSocket, EventSource)

When you DON'T need effects:

  • Event listeners in your template (lit-html handles cleanup automatically)
  • Signal watchers (they're tied to the element lifecycle)

Observed Attributes

Declare observed attributes as the first argument and they'll automatically be available as signals with two-way binding:

shadowElement(
  ['color', 'size'],
  function ColorPicker({ color, size, effect }) {
    // color and size are signals that sync with attributes
    // They default to null if attribute doesn't exist
    
    return () => html`
      <div style="background: ${color.get() || 'blue'}; font-size: ${size.get() || '16px'}">
        Current color: ${color.get()}
        <button @click=${() => color.set('purple')}>
          Change to purple
        </button>
      </div>
    `
  }
)
<color-picker color="red" size="20px"></color-picker>

<script>
  const picker = document.querySelector('color-picker')
  
  // Attribute → Signal → Re-render
  picker.setAttribute('color', 'green')
  
  // Signal → Attribute (reflected automatically)
  // When you click the button, the attribute updates too!
  console.log(picker.getAttribute('color')) // 'purple' (after click)
</script>

Two-Way Binding

The observed attributes create both signals and properties:

shadowElement(
  ['value'],
  function CustomInput({ value }) {
    return () => html`
      <input 
        type="text" 
        .value=${value.get() || ''} 
        @input=${(e) => value.set(e.target.value)}
      >
    `
  }
)
const input = document.querySelector('custom-input')

// All of these are synchronized:
input.setAttribute('value', 'hello')  // Updates signal & property
input.value = 'world'                 // Updates signal & attribute
value.set('foo')                      // Updates property & attribute (in component)

How infinite loops are prevented:

  • Signals have built-in equality checking (equals function)
  • Only updates if the value actually changed

Type Conversion

All attribute values are strings (or null). Convert manually for other types:

shadowElement(
  ['count', 'disabled'],
  function Counter({ count, disabled }) {
    return () => {
      const numCount = parseInt(count.get() || '0')
      const isDisabled = disabled.get() !== null
      
      return html`
        <button 
          ?disabled=${isDisabled}
          @click=${() => count.set(String(numCount + 1))}
        >
          Count: ${numCount}
        </button>
      `
    }
  }
)

Using Element Internals

Access the ElementInternals API for custom element states and ARIA:

shadowElement(
  ['loading'],
  function ProgressButton({ loading, internals }) {
    return () => {
      if (loading.get() !== null) {
        internals.states.add('loading')
        internals.ariaDisabled = 'true'
        internals.ariaBusy = 'true'
      } else {
        internals.states.delete('loading')
        internals.ariaDisabled = 'false'
        internals.ariaBusy = 'false'
      }

      return html`
        <style>
          button {
            padding: 8px 16px;
            cursor: pointer;
          }
          :host(:state(loading)) button {
            opacity: 0.6;
            cursor: wait;
          }
          .spinner {
            display: none;
          }
          :host(:state(loading)) .spinner {
            display: inline-block;
          }
        </style>
        <button>
          <span class="spinner">⏳</span>
          <slot></slot>
        </button>
      `
    }
  }
)
<progress-button id="save">Save Changes</progress-button>

<script type="module">
  const btn = document.querySelector('#save')
  btn.addEventListener('click', async () => {
    btn.setAttribute('loading', '')
    await fetch('/api/save', { method: 'POST' })
    btn.removeAttribute('loading')
  })
</script>

The internals object gives you access to:

  • Custom states (:state() CSS selector)
  • ARIA properties (ariaLabel, ariaDisabled, ariaBusy, etc.)
  • Form participation (setFormValue, setValidity)

Shared State

Using window (Recommended for Static Sites)

For static HTML pages with script tags, the simplest approach is to put your state on window:

<!DOCTYPE html>
<html>
<head>
  <script type="module">
    import { shadowElement, html, state } from 'https://esm.sh/@hot-page/functional-element'

    // Create global store on window
    window.store = {
      cart: state([]),
      user: state(null),
      
      addToCart(item) {
        const current = this.cart.get()
        this.cart.set([...current, item])
      },
      
      login(userData) {
        this.user.set(userData)
      }
    }

    // All components can access window.store
    shadowElement(function cartButton() {
      return () => {
        const items = window.store.cart.get()
        return html`
          <button>
            Cart (${items.length})
          </button>
        `
      }
    })

    shadowElement(function productCard() {
      return () => html`
        <div class="product">
          <h3>Cool Product</h3>
          <button @click=${() => window.store.addToCart({ id: 1, name: 'Cool Product' })}>
            Add to Cart
          </button>
        </div>
      `
    })
  </script>
</head>
<body>
  <cart-button></cart-button>
  <product-card></product-card>
  <product-card></product-card>
</body>
</html>

When you click "Add to Cart", all <cart-button> elements automatically update. No build step, no module bundler, just plain HTML.

Using Module-Level State

If you're using JavaScript modules, you can share state at the module level:

// shared-counter.js
import { shadowElement, html, state } from '@hot-page/functional-element'

// This state is shared across all instances
const sharedCount = state(0)

shadowElement(function SharedCounter() {
  return () => html`
    <button @click=${() => sharedCount.set(sharedCount.get() + 1)}>
      Global count: ${sharedCount.get()}
    </button>
  `
})

Now every <shared-counter> element on the page shows and updates the same count.

Using a Dedicated Store Module

For more complex scenarios with multiple files, create a dedicated store module:

// store.js
import { state } from '@hot-page/functional-element'

export const store = {
  user: state(null),
  theme: state('light'),
  notifications: state([]),
  
  login(userData) {
    this.user.set(userData)
  },
  
  toggleTheme() {
    this.theme.set(this.theme.get() === 'light' ? 'dark' : 'light')
  },
  
  addNotification(message) {
    const current = this.notifications.get()
    this.notifications.set([...current, { id: Date.now(), message }])
  }
}
// user-badge.js
import { shadowElement, html } from '@hot-page/functional-element'
import { store } from './store.js'

shadowElement(function UserBadge() {
  return () => {
    const user = store.user.get()
    return html`
      <div>
        ${user ? html`Hello, ${user.name}!` : html`Not logged in`}
      </div>
    `
  }
})

All components reading from store will automatically re-render when the shared state changes.

Tradeoffs

What you get:

  • Easy reactivity with Signals
  • Much less boilerplate than vanilla Web Components
  • Automatic cleanup with effects
  • Observed attributes support
  • Full TypeScript support

What you give up:

  • Private methods (for public methods, assign them to this)

Prior Art

This was directly inspired by Ginger's post on Piccalilli.

Obviously, React for the simplicity of functional components.

A Hot Page Project

This open-source project is built by the engineeers at Hot Page, a tool for web design and development.