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.
- 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
- 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
- Easy reactivity with Signals
- Much less boilerplate than vanilla Web Components
- Automatic cleanup with effects
- Observed attributes support
- Full TypeScript support
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.
- Load
@hot-page/funfrom a CDN or install it with NPM. - Import one of the define functions:
shadowElementorlightElementas well as thehtmltemplator. Shadow element renders in shadow DOM, and light element renders in normal DOM. - Define your functional component by providing a function.
- Use
stateto create reactive properties - 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.
- You are calling a function
shadowElement. We can call that the "define function". - You are passing a single argument, which is also a function. Let's call that the "setup function".
- 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.
- 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.
- 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. - 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(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() // 1Any 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(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)
)This package provides two define exports:
lightElementwhich will render the template into the element's children.shadowElementwhich 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.
tagNameis your escape hatch. - Adjacent acronyms —
HTMLParserbecomeshtml-parserandCSSAnimationbecomescss-animation, butXMLHTTPRequestbecomesxmlhttp-requestrather thanxml-http-request. Where two acronyms are jammed together there's no way to know where one ends and the other begins. UsetagName.
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.
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 DOM (
shadowElement) gives you full style encapsulation. External CSS can't reach into the shadow root, and your styles can't leak out. Use:hostto style the element itself. - Light DOM (
lightElement) uses@scopeto 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:scopeto style the element itself.
If you copy shadow styles into a light element, remember to swap :host for :scope.
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 --huestyleProps 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.
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
computedor derive inline in the render function)
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>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 (
equalsfunction) - Only updates if the value actually changed
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>
`
}
}
)Observed attributes are always strings. For richer data, use a plain signal with Object.defineProperty to expose a property on the element.
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-rendersetAttribute('color', ...) will have no effect since 'color' is not in attributes.
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 worksThe 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.
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)
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.
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.
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.
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.
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.
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
})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>` // ✅
})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 lexicalthis. If you need to read or write properties on the host element from inside setup, use a namedfunction. - Arrow functions passed inline are anonymous (
.name === ''), so tag name derivation fails. Assign them to a capitalized variable or provide an explicittagName.
// ❌ 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 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 runsEffects 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 changesUse queueMicrotask if you need to read a signal without tracking it (see Lifecycle & Cleanup section).
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 twogetAttribute 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 trueConstructed 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.
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 rootThe 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.
- 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.
This open-source project is built by the engineeers at Hot Page, a tool for web design and development.