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 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hot-page/fun

v0.0.3

Published

Simple define helper for functional web components

Readme

Fun 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

What You Get

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

Prior Art

This was directly inspired by Ginger's post on Piccalilli. Obviously, React for the simplicity of functional components. SolidJS for its use of signals and the idea that functional components can be reactive without running all the time.

Quick Start

  1. Load @hot-page/fun from a CDN or install it with NPM.
  2. Import one of the define functions: shadowElement or lightElement as well as the html templator. Shadow element renders in shadow DOM, and light element renders in normal DOM.
  3. Define your functional component by providing a function.
  4. Use state to create 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/fun'

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

  function onInput(event) {
    // N.B. this will only render the element once 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 "setup function".
  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. You could think of this as the equivalent of creating a class for a custom element.
  2. The setup function will run every time one of your elements on the page is created. This is almost like the constructor() function in an element class.
  3. The render function runs when the reactive properties change and the element's DOM will be updated.

The setup function 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)
  • styleProps - Set CSS custom properties on the host element (see Styling below)
  • observed attributes - Each declared attribute is passed as a signal (see Observed Attributes below)

State

state(value) creates a reactive container. Read its current value with .get(), update it with .set():

const count = state(0)
count.get()     // 0
count.set(1)
count.get()     // 1

Any signal reads inside the render function are tracked — when the signal changes, the element re-renders. State can hold any value: primitives, objects, arrays.

Computed Values

computed(fn) derives a read-only value from one or more signals. It's lazy and cached — the function only re-runs when a signal it depends on changes:

import { shadowElement, html, state, computed } from '@hot-page/fun'

shadowElement(function TemperatureConverter() {
  const celsius = state(0)
  const fahrenheit = computed(() => celsius.get() * 9 / 5 + 32)

  return () => html`
    <input
      type="range" min="-30" max="50"
      .value=${celsius.get()}
      @input=${e => celsius.set(Number(e.target.value))}
    >
    <p>${celsius.get()}°C = ${fahrenheit.get()}°F</p>
  `
})

computed has .get() but no .set(). Computed values can depend on other computed values — the signal graph ensures nothing recomputes more than once per change:

const items = state(['apple', 'banana', 'cherry'])
const count = computed(() => items.get().length)
const isEmpty = computed(() => count.get() === 0)

Like state, computed values can live at module level and be shared across components:

// store.js
export const cart = state([])
export const cartTotal = computed(() =>
  cart.get().reduce((sum, item) => sum + item.price, 0)
)

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/fun'

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

You can also provide a tagName to override the name derived from the function:

define({
  tagName: 'my-element',
  attributes: ['color', 'size'],
  setup({ effect }) {
    const count = state(0)
    return () => html`<p>${count.get()}</p>`
  },
})

I can think of two cases where you'll want this:

  • Minification — these components are so small they barely need minifying, but if you do, bundlers will mangle function names and break the auto-derived tag name. tagName is your escape hatch.
  • Adjacent acronymsHTMLParser becomes html-parser and CSSAnimation becomes css-animation, but XMLHTTPRequest becomes xmlhttp-request rather than xml-http-request. Where two acronyms are jammed together there's no way to know where one ends and the other begins. Use tagName.

Styling

Shadow DOM elements get native style encapsulation, so anything you put in a <style> tag inside your template is scoped to the component. For most cases that's all you need.

Shared stylesheets with styles

When you have more than a line or two of CSS, or when you render many instances of the same element, pass a styles string. Both shadowElement and lightElement support it. This creates a single constructed stylesheet that is shared across every instance of the element — the browser parses the CSS once.

For shadow DOM, the sheet is adopted into each shadow root:

shadowElement(
  `:host {
    display: block;
    padding: 16px;
    background: hsl(var(--hue, 0), 100%, 90%);
  }

  p {
    margin: 0;
  }`,
  function HueSwatch() {
    return () => html`<p>Hello</p>`
  },
)

For light DOM, the sheet is wrapped in @scope and adopted into the document. Use :scope to refer to the host element:

lightElement(
  `:scope {
    display: block;
    padding: 16px;
  }

  p {
    margin: 0;
  }`,
  function MyCard() {
    return () => html`<p>Hello</p>`
  },
)

The styles argument goes between attributes (if any) and the setup function. Both define functions accept the same overloads:

shadowElement(fn)                    // no attrs, no styles
shadowElement(styles, fn)            // styles only
shadowElement(attrs, fn)             // attrs only
shadowElement(attrs, styles, fn)     // both

lightElement(fn)
lightElement(styles, fn)
lightElement(attrs, fn)
lightElement(attrs, styles, fn)

Or via define():

define({
  attributes: ['color'],
  useShadow: true,
  styles: `:host { display: block; }`,
  setup: function MyElement({ color }) {
    return () => html`<p>${color.get()}</p>`
  }
})

You can still use <style> tags inside templates, and they coexist fine with styles — but the constructed stylesheet approach is more efficient for styles that don't change per-render.

Shadow vs. light: what's different

  • Shadow DOM (shadowElement) gives you full style encapsulation. External CSS can't reach into the shadow root, and your styles can't leak out. Use :host to style the element itself.
  • Light DOM (lightElement) uses @scope to limit where your selectors match, but this is not encapsulation. External CSS can still target elements inside your component, and specificity rules still apply as normal. Use :scope to style the element itself.

If you copy shadow styles into a light element, remember to swap :host for :scope.

Per-instance styling with styleProps

CSS custom properties are the platform's answer to per-instance styling: set them on the host, they cascade into the component. The styleProps helper is a shortcut for setting multiple custom properties at once without typing this.style.setProperty over and over:

shadowElement(
  `:host {
    display: block;
    background: hsl(var(--hue), var(--saturation), 50%);
  }`,
  function HueSlider({ styleProps }) {
    function onInput(event) {
      styleProps({
        hue: event.target.value,
        saturation: '80%'
      })
    }

    return () => html`
      <input type="range" min="0" max="360" @input=${onInput}>
    `
  },
)

Keys are converted from camelCase to kebab-case and prefixed with --. So hueShift becomes --hue-shift. Numbers are coerced to strings. Passing null removes the property:

styleProps({ hue: 180 })        // --hue: 180
styleProps({ hueShift: '45' })  // --hue-shift: 45
styleProps({ hue: null })       // removes --hue

styleProps merges with whatever is already on this.style — it only touches the keys you pass.

Use styleProps when you want to update visual state from an event handler without triggering a template re-render. Writing to a signal would re-run the render function even if only the CSS changed; styleProps skips that entirely.

Lifecycle & Cleanup

Use the effect function to register side effects that automatically track signal dependencies:

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:

  • Effects run when the element connects to the DOM
  • Effects automatically re-run whenever any signal read inside them changes
  • Cleanup functions run before re-running the effect, and when the element disconnects
  • If an element is moved in the DOM, cleanup runs, then effects run again

Reactive effects:

Effects automatically track any signals you read inside them and re-run when those signals change:

shadowElement(['size'], function Button({ size, effect }) {
  const validSizes = ['sm', 'md', 'lg']
  
  effect(() => {
    const value = size.get() // Automatically tracked!
    if (!validSizes.includes(value)) {
      console.error(`Invalid size: "${value}". Expected: ${validSizes.join(', ')}`)
    }
  })
  
  return () => html`<button class="${size.get()}">Click me</button>`
})

When the size attribute changes, the effect re-runs and validates the new value.

Syncing to external APIs:

shadowElement(['theme'], function ThemeStorage({ theme, effect }) {
   // N.B. a real app would use try/catch and validation
  theme.set(localStorage.theme || 'light')

  effect(() => localStorage.theme = theme.get())
})
<theme-storage theme="dark">
  ...rest of your app uses [theme=light/dark] selectors...
</theme-storage>

<script>
  // Update from anywhere in your app and the effect runs, saving the value to local storage
  document.querySelector('theme-storage').theme = 'dark'
</script>

Dynamic subscriptions with cleanup:

shadowElement(['userId'], function UserProfile({ userId, effect }) {
  const user = state(null)
  
  effect(() => {
    const id = userId.get()
    if (!id) return
    
    // Subscribe when userId changes
    const unsubscribe = subscribeToUser(id, data => user.set(data))
    
    // `unsubscribe` runs before re-subscribing to a new user
    return unsubscribe
  })
  
  return () => html`<div>${user.get()?.name || 'Loading...'}</div>`
})

When userId changes, the previous subscription cleanup runs, then the effect re-runs and subscribes to the new user.

Roll-your-own reactivity:

You can skip lit-html entirely and manipulate the DOM directly in an effect:

shadowElement(function ManualCounter({ effect }) {
  const count = state(1)

  effect(() => {
    // Update DOM manually when count changes
    const span = this.shadowRoot.querySelector('#value')
    span.textContent = count.get()
  })

  this.addEventListener('click', (event) => {
    if (event.target.closest('#increment')) {
      count.set(count.get() + 1)
    } else if (event.target.closest('#decrement')) {
      count.set(count.get() - 1)
    }
  })

  return `
    <button id="decrement">-</button>
    <span id="value"></span>
    <button id="increment">+</button>
  `
})

The first effect tracks count and updates the DOM when it changes. The second effect doesn't track any signals, so it runs once to set up event listeners.

Non-reactive effects:

If your effect doesn't read any signals, it behaves like a simple mount/unmount handler:

effect(() => {
  console.log('Mounted!')
  return () => console.log('Unmounted!')
})

This runs once on mount and cleanup runs on unmount — no re-runs since it doesn't track any signals.

Avoiding unintended tracking:

If you need to read a signal's value without tracking it (rare), use queueMicrotask:

effect(() => {
  const reactiveValue = count.get() // Tracked
  
  queueMicrotask(() => {
    const snapshot = otherSignal.get() // Not tracked
    doSomething(snapshot)
  })
})

When you need effects:

  • Validation with side effects (logging errors, showing warnings)
  • Syncing to external storage (localStorage, IndexedDB)
  • Global event listeners (window, document)
  • Timers that depend on component state
  • Observers (IntersectionObserver, MutationObserver)
  • External subscriptions (WebSocket, EventSource, Firebase)

When you DON'T need effects:

  • Event listeners in your template (lit-html handles cleanup automatically)
  • Pure computations (use computed or derive inline in the render function)

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>
      `
    }
  }
)

Element Properties

Observed attributes are always strings. For richer data, use a plain signal with Object.defineProperty to expose a property on the element.

JS-only property (no attribute reflection)

Use this when you want to pass objects or other non-string values to an element, and don't need setAttribute to work:

shadowElement(function ColorPicker() {
  const color = state({ red: 0, green: 0, blue: 0 })

  Object.defineProperty(this, 'color', {
    get() { return color.get() },
    set(value) { color.set(value) },
  })

  return () => html`
    <p>Red: ${color.get().red}</p>
  `
})
const picker = document.querySelector('color-picker')
picker.color = { red: 255, green: 0, blue: 0 } // triggers re-render

setAttribute('color', ...) will have no effect since 'color' is not in attributes.

Observed attribute with custom property setter

Use this when you want both setAttribute to work and the property to accept richer values. Declare the attribute normally to get signal and attributeChangedCallback wiring, then override the property:

shadowElement(['color'], function ColorPicker({ color }) {

  Object.defineProperty(this, 'color', {
    get() { return color.get() },
    set(value) {
      // Accept objects by serializing to a string for the attribute
      color.set(typeof value === 'string' ? value : JSON.stringify(value))
    },
  })

  return () => {
    const val = color.get()
    const parsed = val ? JSON.parse(val) : { red: 0 }
    return html`<p>Red: ${parsed.red}</p>`
  }
})
const picker = document.querySelector('color-picker')
picker.color = { red: 255 }          // sets attribute to '{"red":255}'
picker.setAttribute('color', '{"red":128}')  // also works

The tradeoff: the attribute value is JSON, which is readable but not pretty in the DOM. If you don't need setAttribute support, the JS-only pattern above is cleaner.

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)

Form participation

To use the form participation APIs (setFormValue, setValidity, etc.), you must opt in with formAssociated: true. Without it the browser will throw when you call those methods.

define({
  attributes: ['value'],
  formAssociated: true,
  setup({ value, internals }) {
    return () => {
      internals.setFormValue(value.get())

      return html`
        <input
          type="text"
          .value=${value.get() || ''}
          @input=${(e) => value.set(e.target.value)}
        >
      `
    }
  }
})

Custom states and ARIA properties work without formAssociated — you only need it if you're integrating with <form> elements.

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/fun'

    // 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/fun'

// 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, computed } from '@hot-page/fun'

export const store = {
  user: state(null),
  theme: state('light'),
  notifications: state([]),
  notificationCount: computed(() => store.notifications.get().length),
  
  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/fun'
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.

What the Setup Function Can Return

The setup function can return different things depending on how much reactivity you need:

| Return value | Behavior | |---|---| | Function | Reactive. Called on every signal change to re-render the element. | | Template (html\...`) | Rendered once. No reactivity — signals read inside won't trigger updates. | | **String** | Rendered once as HTML markup via innerHTML. If the render function returns a string, each update also goes through innerHTML. | | **Nothing** (undefined) | Nothing is rendered. Useful when the setup function only registers effects or sets properties. | | **Anything else** | Logs a console.error` at construction time. |

The typical pattern is to return a render function so the element reacts to signal changes:

shadowElement(function MyEl() {
  const count = state(0)

  // ✅ function — reactive
  return () => html`<p>${count.get()}</p>`
})

The render function doesn't have to return a template. If it returns nothing, it's still called whenever signals change — you just manage the output yourself. This is useful when you want reactivity without handing DOM control to lit-html.

For example, an element that takes a theme attribute and sets its colors directly — no template needed, just style updates:

shadowElement(['theme'], function ThemedBox({ theme }) {
  return () => {
    const isDark = theme.get() === 'dark'
    this.style.background = isDark ? '#1a1a2e' : '#f5f0e8'
    this.style.color = isDark ? '#ffffff' : '#000000'
    // no return — render function just sets styles
  }
})
<themed-box theme="dark">Dark mode content</themed-box>
<themed-box theme="light">Light mode content</themed-box>

Change the theme attribute and the colors update reactively.

Or an element that manages its own DOM with innerHTML:

shadowElement(['items'], function RawRenderer({ items }) {
  return () => {
    this.shadowRoot.innerHTML = (items.get() ?? '')
      .split(' ')
      .map(item => `<li>${item.trim()}</li>`)
      .join('')
    // no return — we've already written to the DOM directly
  }
})
<raw-renderer items="one two three"></raw-renderer>

Both are fully reactive: the function reruns whenever any signal read inside it changes.

You can also conditionally return nothing — it's a no-op:

shadowElement(['mode'], function ConditionalRender({ mode }) {
  return () => {
    if (mode.get() === 'custom') {
      // manage DOM yourself
      this.shadowRoot.innerHTML = '<p>Custom render</p>'
      return // no-op, we already rendered
    }
    // otherwise return a template
    return html`<p>Standard render</p>`
  }
})

If you want to render something that will never change, you can return a template directly:

shadowElement(function StaticGreeting() {
  // ✅ template — rendered once, no reactivity
  return html`<p>Hello, world!</p>`
})

Returning nothing is fine when your setup only needs side effects:

shadowElement(function SideEffectOnly({ effect }) {
  effect(() => {
    console.log('connected')
    return () => console.log('disconnected')
  })

  // ✅ no return — nothing rendered
})

Returning a plain string is a shortcut for fully static markup you control:

shadowElement(function Disclaimer() {
  return '<p>All prices include VAT.</p>'
})

A render function can also return a string, in which case each reactive update sets innerHTML with the new value:

shadowElement(function Greeting() {
  const name = state('world')

  return () => `<p>Hello, ${name.get()}!</p>`
})

The same rule applies: this is direct innerHTML assignment, so only use it with content you control.

Do not put user input in a string return. The string goes straight into innerHTML with no escaping, so any HTML it contains is executed. If the content comes from a user, a database, or anywhere outside your source code, use html\...`` instead — lit-html escapes expression values by default:

// ❌ XSS: userBio could contain <script>...</script>
return `<p>${userBio}</p>`

// ✅ Safe: lit-html escapes the value
return () => html`<p>${userBio}</p>`

This library is for developers who know what they're putting in their markup and when to reach for html\...``. It doesn't try to protect you from yourself.

Any other return type (number, object, array, etc.) logs a console.error immediately so you catch the mistake early.

Gotchas

Call signal.get() inside the render function

Signals only track reads that happen during rendering. If you read a signal in the setup function body, you capture a snapshot — not a live reference:

shadowElement(function MyEl() {
  const count = state(0)
  const value = count.get() // ❌ captured once, never updates

  return () => html`<p>${value}</p>`
})
shadowElement(function MyEl() {
  const count = state(0)

  return () => html`<p>${count.get()}</p>` // ✅ read during render, reactive
})

Don't destructure signal values in the setup function body

Same issue. Destructuring reads the value once at setup time:

shadowElement(function MyEl() {
  const color = state({ red: 0, green: 0, blue: 0 })
  const { red } = color.get() // ❌ captured once

  return () => html`<p>${red}</p>`
})
shadowElement(function MyEl() {
  const color = state({ red: 0, green: 0, blue: 0 })

  return () => html`<p>${color.get().red}</p>` // ✅
})

Arrow functions have caveats

The library does two things with your setup function: it calls it with this bound to the element, and it reads .name to derive the tag name. Arrow functions don't play nicely with either:

  • Arrow functions ignore .call(this, ...) — they use lexical this. If you need to read or write properties on the host element from inside setup, use a named function.
  • Arrow functions passed inline are anonymous (.name === ''), so tag name derivation fails. Assign them to a capitalized variable or provide an explicit tagName.
// ❌ anonymous — no name to derive tag from
shadowElement(() => { ... })

// ❌ anonymous, same problem
shadowElement(function() { ... })

// ✅ named function — preferred, and lets you use `this`
shadowElement(function MyEl() { ... })

// ✅ arrow works if you provide tagName and don't need `this`
define({
  tagName: 'my-el',
  setup: () => () => html`<p>hi</p>`,
})

Effects run on connect, not on construction

Effects are registered during the setup function call but don't run until the element is connected to the DOM. If you construct an element programmatically without appending it, effects haven't fired yet:

const el = document.createElement('my-counter')
// effect hasn't run yet

document.body.appendChild(el)
// now it runs

Effects track signal reads automatically

Effects re-run whenever any signal read inside them changes. This is usually what you want, but watch out for:

Infinite loops — writing to a signal you're reading in the same effect can cause rapid re-runs:

// ⚠️  Will re-run rapidly until stopped
effect(() => {
  count.set(count.get() + 1) // Reads then writes
})

// ✅ OK — conditional limits execution
effect(() => {
  const val = count.get()
  if (val < 10) {
    count.set(val + 1)
  }
})

Always add guards when writing to tracked signals within an effect.

Unintended tracking — reading a signal always tracks it, even in conditionals:

effect(() => {
  if (enabled.get()) {
    console.log(value.get())
  }
})
// Tracks both `enabled` and `value` once enabled is true
// Effect re-runs when either changes

Use queueMicrotask if you need to read a signal without tracking it (see Lifecycle & Cleanup section).

Setting multiple signals triggers one render

Updating several signals in a row is coalesced into a single render on the next microtask. This is a feature, but it means you can't observe intermediate state between sets:

count.set(1)
label.set('updated')
// one render, not two

Absent attributes are null, not undefined

getAttribute follows the DOM spec and returns null for missing attributes, never undefined. Check accordingly:

if (count.get() === null) { ... }  // ✅ attribute is absent
if (count.get() === undefined) { ... }  // ❌ never true

styles and cross-document adoption

Constructed stylesheets are bound to the Document that created them. If a custom element is moved to a different document (for example via document.adoptNode() into an iframe or a popup window), the stylesheet from the original document is no longer usable in the new one, and the element's styles will stop applying.

This is rare — most apps never move elements across documents — and this library doesn't handle it. If you need to support that scenario, avoid the styles option and put a <style> tag inside your template instead.

Rendering SVG

This package also exports a svg tagged template literal (re-exported from lit-html). Use it instead of html only when the root of your template is an SVG element — for example when writing a custom SVG shape or icon component:

import { shadowElement, svg, state } from '@hot-page/fun'

shadowElement(function AnimatedCircle() {
  const r = state(10)
  return () => svg`<circle cx="50" cy="50" r="${r.get()}" fill="red" />`
})
<svg>
  <animated-circle></animated-circle>
</svg>

If your template starts with an HTML element — even one that contains <svg> inside — use html as normal:

return () => html`<div><svg>...</svg></div>` // ✅ use html, not svg
return () => svg`<circle ... />`             // ✅ use svg only at SVG root

The distinction matters because lit-html uses the tag to parse the template in the correct namespace context. Using html for SVG roots will result in elements created in the HTML namespace, which browsers won't render correctly.

Roadmap

  • Typed attributes — Declare typed attributes so the framework converts them automatically, rather than having to parse strings manually in each component. Types: integer, float, boolean, token list, JSON, function. Yes, function for more fun.

A Hot Page Project

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