A reactive HTML component library with hooks-based lifecycle management
npm install hooktml

HTML-first behavior with functional hooks: declarative, composable, and lightweight.
HookTML is a JavaScript library that lets you add interactive behavior to HTML without sacrificing control over your markup. It combines:
- HTML-first development - Your markup stays in charge, not JavaScript templates
- Functional composition - Use React-style hooks to share and reuse behavior
- Minimal abstraction - Work directly with the real DOM, not a virtual one
- 🔍 Zero rendering system - Works directly with your HTML, no templating required
- 🧩 Composable hooks - Mix and match behavior with functional hooks
- 🔌 Declarative attributes - Control behavior directly from your markup
- ⚡ Reactive computed signals - Automatically derived values that update when dependencies change
- 🧹 Automatic cleanup - No manual lifecycle management
- 🚀 Progressive enhancement - Perfect for server-rendered apps
Why I Built HookTML: React Vibes, Stimulus Roots 📝
Read the story behind HookTML's creation: from the context-switching struggles between React and Stimulus, to building a library that bridges functional composition with HTML-first development.
See HookTML in action with these interactive examples:
- Currency Converter - Real-time reactive updates with signals
- Todo App - Component communication and state management
- Modal Dialog - Handling external triggers
- Tabs Component - Array support and element collections
- Counter - Purely using a hook
All examples use the CDN - no build step required! Fork and experiment.
``html`
counter-increment
use-tooltip="Click to increase the count"
>
Increment
0
`js
import { signal, useText, useEvents } from 'hooktml';
export const Counter = (el, props) => {
const { increment, display } = props.children;
const count = signal(0);
useText(display, () => ${count.value}, [count]);
useEvents(increment, {
click: () => {
count.value += 1;
}
});
return () => count.destroy();
};
`
HookTML gives you a simple way to organize UI behavior without the complexity of modern frameworks or the limitations of vanilla JavaScript.
> For developers familiar with other libraries: If you love how Stimulus keeps things close to the markup, but miss how React lets you compose and reuse behavior, HookTML bridges the gap.
---
1. Installation & Setup
2. Core Concepts
3. Hooks
4. Components
5. Styling
6. API Reference
7. Advanced Patterns
8. Examples & Recipes
9. Integration
10. Philosophy & Limitations
---
You can use HookTML directly in the browser via
`
For projects that don't use ES modules, you can include HookTML as a global script:
`html`
You can also download and host the file locally:
`html`
`bash`
npm install hooktmlor
yarn add hooktml
`js`
import HookTML from 'hooktml';
HookTML.start();
`js`
HookTML.start({
componentPath: "/js/components", // optional folder to auto-register components (Node.js only)
debug: false, // optional debug logs
attributePrefix: "data" // optional prefix for all attributes
});
Note: The componentPath option works in Node.js environments. For bundler environments, it requires static analysis support.
The attributePrefix option allows you to namespace all HookTML attributes. When set, all hooks, components, and props will be prefixed with the specified value. For example, with attributePrefix: "data":
`html`
This is particularly useful when integrating with frameworks that have specific conventions for custom attributes.
---
HookTML uses a simple mental model built around three key concepts:
With HookTML, your HTML remains the source of truth. Instead of generating markup from JavaScript, you enhance existing HTML with behaviors. This keeps your DOM clean, semantic, and accessible by default.
Hooks are reusable behaviors that can be applied directly to any element using use-* attributes:
`html`
This declarative approach means behaviors are visible right in your markup - no hidden JavaScript wiring.
When elements need to work together or share state, components let you group related behaviors:
`html`
Or alternatively using the attribute syntax:
`html`
Components automatically locate and interact with their children elements.
- Use hooks directly for simple, isolated behaviors (tooltips, focus handling, analytics)
- Create components when multiple elements need to interact or share state (tabs, forms, modals)
HookTML embraces attributes as the way to connect markup to behavior:
- use-* attributes apply hooks to elementsdialog-header
- Component-prefixed attributes identify children ()dialog-open="true"
- State is reflected with attributes rather than classes ()
This makes your UI's behavior visible and inspectable directly in the HTML.
---
Hooks are reusable behaviors applied to individual elements using use-* attributes.
Hooks encapsulate self-contained behaviors like tooltips, analytics tracking, or form validation. They:
- Keep behavior close to the elements they affect
- Can be composed (multiple hooks on one element)
- Clean up automatically when elements are removed
Any attribute starting with use- automatically invokes a matching hook function:
`html`
This calls useTooltip(el, props) and passes "Click to save" as props.value.
You can also pass additional props using matching custom attributes:
`html`
use-tooltip="Click to save"
tooltip-placement="top"
tooltip-color="blue"
>
Save
This becomes:
`js`
props = {
value: "Click to save",
placement: "top",
color: "blue"
};
Values are automatically coerced:
`html`
Hooks can also manage groups of related elements using the useChildren helper:
`html`
Hidden content
`js`
export const useToggle = (el, props) => {
// Query for elements with toggle-* attributes
const children = useChildren(el, "toggle");
const { button, content } = children;
useEvents(button, {
click: () => {
content.toggleAttribute("hidden");
}
});
};
The useChildren helper provides consistent access to child elements through both singular and plural keys:
- Single element: { button: HTMLElement, buttons: [HTMLElement] }{ button: HTMLElement, buttons: [HTMLElement, HTMLElement] }
- Multiple elements:
This means you can always choose the access pattern that fits your needs:
- Use singular keys (button) when you need the first elementbuttons
- Use plural keys () when you need to work with all elements
`js
// Always available - no conditional checks needed
const { button, buttons, content, contents } = useChildren(el, "toggle");
// Work with the first element
button.focus();
// Work with all elements
buttons.forEach(btn => btn.disabled = true);
`
This pattern lets hooks manage their own scoped child elements, similar to how components work, but with a more focused behavior that can be attached directly to elements.
A custom hook is a function that receives an element and props:
`js`
(el: HTMLElement, props: object) => (() => void)?
You can use any native DOM APIs, other hooks, or internal helpers:
`js`
export const useFocusRing = (el, props) => {
useEvents(el, {
focus: () => el.classList.add("has-focus"),
blur: () => el.classList.remove("has-focus")
});
// Optional cleanup function
return () => {
el.classList.remove("has-focus");
};
};
HookTML will automatically run this if you write:
`html`
You can attach multiple hooks to a single element:
`html`
use-analytics="form-submit"
use-focus-ring
>
Submit
Each hook is initialized independently and receives its own props, scoped by its prefix:
`js`
useTooltip(el, { value: "Click to submit" })
useAnalytics(el, { value: "form-submit" })
useFocusRing(el, {}) // — no props
Hooks are:
1. Initialized when the element appears in the DOM
2. Updated if their attributes change
3. Cleaned up when the element is removed
If a hook returns a function, it will be called during cleanup:
`js`
return () => {
// Clean up resources, event listeners, etc.
};
---
Components are functions that group hooks and behaviors to coordinate multiple elements.
Components organize related elements and their behaviors. They:
- Find and interact with child elements
- Manage shared state
- Coordinate behavior between elements
- Provide a common cleanup function
While both components and hooks can group behavior, they differ in important ways:
1. Automatic Binding:
- Components are automatically bound to elements with matching class names (class="Dialog") or use-component attributes (use-component="Dialog")use-*
- Hooks must be explicitly attached with attributes
2. Child Element Access:
- Components automatically collect all children with matching prefixed attributes (dialog-header) into props.childrenuseChildren()
- Hooks must explicitly call to access child elements
3. Purpose:
- Components are designed for organizing larger UI sections and coordinating multiple elements
- Hooks are designed for reusable, composable behaviors that can be mixed and matched
4. Scope:
- Components typically define the scope boundary for a set of related elements
- Hooks typically enhance individual elements or small groups of elements within a component
Think of components as containers that provide structure and coordination, while hooks provide specific behaviors that can be composed together.
Components are bound to elements using either a class name or a use-component attribute:
`html`
Both approaches bind the Counter function to the element.
You can register components manually (recommended for browser environments):
`js`
import { registerComponent } from 'hooktml';
registerComponent(Counter);
Or let HookTML auto-register them from a directory:
`js`
HookTML.start({
componentPath: "/js/components"
});
Auto-registration Environment Support:
- Node.js environments: Auto-registers all components in the specified directory
- Bundler environments: Limited due to static analysis requirements - manual registration recommended
- Browser environments: Manual registration required
If auto-registration isn't available, use registerComponent() to register components manually.
Child elements are auto-bound using lowercase attributes prefixed with the component name:
`html`
Content
In the component function:
`js`
export const Dialog = (el, props) => {
const { header, body, footer } = props.children;
// Now you can work with these DOM elements
header.classList.add('text-lg');
};
Children are matched based on attribute—not tag, class, or ID—and returned as actual DOM elements. They return both singular and plural keys, regardless of how many elements are found.
`js
const { items, item } = props.children;
// items returns an array of all matching elements
items.forEach(item => item.classList.add('list-item'));
// item returns the first matching element
item.focus();
`
To pass props into a component, use custom attributes prefixed with the component name:
`html`
class="Modal"
modal-open="true"
modal-size="lg"
>
Which becomes:
`js`
props = {
open: true,
size: "lg"
};
Components follow the same lifecycle as hooks:
1. Initialized when the element appears
2. Updated if their attributes change
3. Cleaned up when removed
Components can return a simple cleanup function:
`js`
return () => {
// Clean up resources
};
Or a more complex object with context:
`js`
return {
cleanup: () => {
// Clean up resources
},
context: {
// Methods and data to expose to other components
open, close, isOpen
}
};
---
HookTML encourages writing CSS that mirrors your component structure, using attribute selectors for state.
You can attach styles directly to a component using Component.styles. These are injected once into a global