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.
<section class="Counter">
<button
counter-increment
use-tooltip="Click to increase the count"
>
Increment
</button>
<strong counter-display>0</strong>
</section>
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.
- Installation & Setup
- Core Concepts
- Hooks
- Components
- Styling
- API Reference
- Advanced Patterns
- Examples & Recipes
- Integration
- Philosophy & Limitations
You can use HookTML directly in the browser via <script type="module">
or install it with your preferred package manager.
<script type="module">
import HookTML from 'https://unpkg.com/hooktml';
HookTML.start();
</script>
For projects that don't use ES modules, you can include HookTML as a global script:
<script src="https://unpkg.com/hooktml@latest/dist/hooktml.min.js"></script>
<script>
// HookTML is now available globally
HookTML.start();
</script>
You can also download and host the file locally:
<script src="./js/hooktml.min.js"></script>
<script>
// Destructure what you need from HookTML
const { start, signal, useText, registerComponent, useEvents } = HookTML;
// Register a custom component
function MyCounter(el, props) {
const { increment, display } = props.children;
const count = signal(0);
useText(display, () => count.value, [count]);
useEvents(increment, {
click: () => count.value = count.value + 1
});
}
registerComponent(MyCounter);
// Start the runtime
start();
</script>
npm install hooktml
# or
yarn add hooktml
import HookTML from 'hooktml';
HookTML.start();
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"
:
<div data-use-component="Dialog">
<button data-dialog-close>Close</button>
</div>
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:
<button use-tooltip="Click me">Save</button>
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:
<section class="Dialog">
<header dialog-header>Title</header>
<button dialog-close>×</button>
</section>
Or alternatively using the attribute syntax:
<section use-component="Dialog">
<header dialog-header>Title</header>
<button dialog-close>×</button>
</section>
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 elements- Component-prefixed attributes identify children (
dialog-header
) - State is reflected with attributes rather than classes (
dialog-open="true"
)
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:
<button use-tooltip="Click to save">Save</button>
This calls useTooltip(el, props)
and passes "Click to save"
as props.value
.
You can also pass additional props using matching custom attributes:
<button
use-tooltip="Click to save"
tooltip-placement="top"
tooltip-color="blue"
>
Save
</button>
This becomes:
props = {
value: "Click to save",
placement: "top",
color: "blue"
};
Values are automatically coerced:
<button use-tooltip> <!-- props = {} -->
<button use-tooltip=""> <!-- props = {} -->
<button use-tooltip="Hello world"> <!-- props = { value: "Hello world" } -->
<button use-tooltip="42"> <!-- props = { value: 42 } -->
<button use-tooltip="true"> <!-- props = { value: true } -->
<button use-tooltip="false"> <!-- props = { value: false } -->
<button use-tooltip="null"> <!-- props = { value: null } -->
Hooks can also manage groups of related elements using the useChildren
helper:
<div use-toggle>
<button toggle-button>Toggle</button>
<div toggle-content hidden>Hidden content</div>
</div>
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] }
- Multiple elements:
{ button: HTMLElement, buttons: [HTMLElement, HTMLElement] }
This means you can always choose the access pattern that fits your needs:
- Use singular keys (
button
) when you need the first element - Use plural keys (
buttons
) when you need to work with all elements
// 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:
(el: HTMLElement, props: object) => (() => void)?
You can use any native DOM APIs, other hooks, or internal helpers:
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:
<input use-focus-ring />
You can attach multiple hooks to a single element:
<button
use-tooltip="Click to submit"
use-analytics="form-submit"
use-focus-ring
>
Submit
</button>
Each hook is initialized independently and receives its own props
, scoped by its prefix:
useTooltip(el, { value: "Click to submit" })
useAnalytics(el, { value: "form-submit" })
useFocusRing(el, {}) // — no props
Hooks are:
- Initialized when the element appears in the DOM
- Updated if their attributes change
- Cleaned up when the element is removed
If a hook returns a function, it will be called during cleanup:
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:
-
Automatic Binding:
- Components are automatically bound to elements with matching class names (
class="Dialog"
) or use-component attributes (use-component="Dialog"
) - Hooks must be explicitly attached with
use-*
attributes
- Components are automatically bound to elements with matching class names (
-
Child Element Access:
- Components automatically collect all children with matching prefixed attributes (
dialog-header
) intoprops.children
- Hooks must explicitly call
useChildren()
to access child elements
- Components automatically collect all children with matching prefixed attributes (
-
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
-
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:
<section class="Counter"></section>
<!-- or -->
<section use-component="Counter"></section>
Both approaches bind the Counter
function to the element.
You can register components manually (recommended for browser environments):
import { registerComponent } from 'hooktml';
registerComponent(Counter);
Or let HookTML auto-register them from a directory:
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:
<section class="Dialog">
<header dialog-header>Title</header>
<div dialog-body>Content</div>
<footer dialog-footer>Actions</footer>
</section>
In the component function:
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.
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:
<section
class="Modal"
modal-open="true"
modal-size="lg"
></section>
Which becomes:
props = {
open: true,
size: "lg"
};
Components follow the same lifecycle as hooks:
- Initialized when the element appears
- Updated if their attributes change
- Cleaned up when removed
Components can return a simple cleanup function:
return () => {
// Clean up resources
};
Or a more complex object with context:
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 <style>
tag and scoped by the component's class:
export const Dialog = (el, props) => {
if (props.size) el.setAttribute("dialog-size", props.size);
if (props.error) el.setAttribute("dialog-error", "");
};
Dialog.styles = `
padding: 1rem;
border: 1px solid #ccc;
& .Header {
font-weight: bold;
}
&[dialog-size="sm"] {
max-width: 300px;
}
&[dialog-size="lg"] {
max-width: 800px;
}
&[dialog-error] {
border-color: red;
}
`;
While Component.styles
is convenient for co-locating styles with behavior, it's completely optional. Since HookTML uses class names for components by default, you can simply write standard CSS in separate files:
/* styles.css */
.Dialog {
padding: 1rem;
border: 1px solid #ccc;
}
.Dialog .Header {
font-weight: bold;
}
.Dialog[dialog-size="sm"] {
max-width: 300px;
}
.Dialog[dialog-size="lg"] {
max-width: 800px;
}
.Dialog[dialog-error] {
border-color: red;
}
This flexibility allows you to use whatever CSS organization approach works best for your project, including CSS preprocessors, CSS modules, or utility class systems.
Rather than toggling classes, we recommend using attributes to reflect state and variants:
el.setAttribute("button-loading", "");
.Button[button-loading] {
opacity: 0.5;
pointer-events: none;
}
This is easier to debug in DevTools and avoids class name drift.
HookTML provides specialized hooks for updating content and styles declaratively in your JavaScript:
// Set text content reactively
useText(span, () => `Count: ${count.value}`, [count]);
// Apply classes conditionally
useClasses(button, {
'is-active': isActive,
'is-disabled': isDisabled
});
// Set inline styles
useStyles(modal, {
maxHeight: `${window.innerHeight * 0.8}px`,
zIndex: 100
});
// Set attributes (good for both styling and ARIA)
useAttributes(toggle, {
'aria-expanded': isOpen,
'data-state': isOpen ? 'expanded' : 'collapsed'
});
Most utility hooks support arrays of elements with per-element logic using functions that receive both the element and its index:
// Direct signal values are automatically tracked (no deps needed)
useClasses(tabButtons, {
active: (btn) => btn.dataset.selected === 'true',
first: (btn, index) => index === 0,
disabled: isGloballyDisabled // Signal automatically detected
});
useStyles(cardElements, {
backgroundColor: (card) => card.dataset.theme,
zIndex: (card, index) => 100 + index,
opacity: fadeLevel // Signal automatically detected
});
useAttributes(menuItems, {
'aria-label': (item) => `Menu item: ${item.textContent}`,
'tabindex': (item, index) => index === 0 ? '0' : '-1',
'data-visible': isMenuOpen // Signal automatically detected
});
// Events work with arrays too (handlers receive event and index)
useEvents(tabButtons, {
click: (event, index) => activeTab.value = index
});
When functions access signals (using .value
), add them to the dependencies array for reactivity:
// These functions read signals, so deps are required for reactivity
useClasses(buttons, {
active: (btn, index) => selectedTab.value === index
}, [selectedTab]);
useStyles(panels, {
opacity: (panel, index) => activePanel.value === index ? 1 : 0.5,
transform: (panel, index) => isAnimating.value ? 'scale(0.95)' : 'scale(1)'
}, [activePanel, isAnimating]);
useAttributes(toggles, {
'aria-expanded': (toggle, index) => openItems.value.includes(index) ? 'true' : 'false'
}, [openItems]);
These hooks make your styling logic more readable and maintainable, whether working with single elements or multiple elements. See the API Reference section for complete details on these utility hooks.
HookTML automatically hides elements with data-hooktml-cloak
until they're initialized:
<section class="Dialog" data-hooktml-cloak></section>
[data-hooktml-cloak] {
display: none !important;
}
The data-hooktml-cloak
attribute is removed automatically once behavior is ready.
Function | Description |
---|---|
start(options) |
Initialize the library with optional configuration |
registerComponent(Component) |
Register a component function |
registerHook(useHook) |
Register a hook function |
registerChainableHook(useHook) |
Register a hook for use with the with() chainable API |
signal(initialValue) |
Create a reactive value |
computed(computeFn) |
Create a computed signal that automatically updates when dependencies change |
useEffect(callback, deps) |
Run code when dependencies change |
Hook | Description |
---|---|
useEvents(el, eventMap, deps?) |
Bind multiple events declaratively. Supports arrays of elements and EventTargets (HTMLElement, Document, Window) |
useStyles(el, styleObject, deps?) |
Apply inline styles. Supports arrays with per-element functions |
useAttributes(el, attrMap, deps?) |
Set DOM attributes. Supports arrays with per-element functions |
useClasses(el, classMap, deps?) |
Toggle class names based on conditions. Supports arrays with per-element functions |
useText(el, textFunction, deps?) |
Set text content on an element. Function receives element and returns text to display |
useChildren(el, prefix) |
Query child elements with a specific prefix, returning both singular and plural keys for consistent access |
Components can return:
// Simple cleanup function
return () => { ... };
// Or object with context and cleanup
return {
cleanup: () => { ... },
context: { ... }
};
HookTML provides a chainable API for composing behaviors:
with(el)
.useEvents({ click: onClick })
.useClasses({ active: isActive })
.useAttributes({ "aria-expanded": isOpen })
.useText(() => `Hello ${firstName}`)
.cleanup();
For more readable, declarative code, use the with()
helper:
export const useTooltip = (el, { value }) => {
const show = () => { /* ... */ };
const hide = () => { /* ... */ };
return with(el)
.useEvents({ mouseenter: show, mouseleave: hide })
.useAttributes({ "aria-label": value })
.useClasses({
"tooltip-visible": true,
"text-sm": true
})
.cleanup();
};
You can extend the chainable API with your own hooks, making them available through the with()
helper:
import { registerChainableHook } from 'hooktml';
// First create your hook function
export const useRipple = (el, options = {}) => {
// Ripple effect implementation
const addRipple = (e) => { /* ... */ };
useEvents(el, {
mousedown: addRipple
});
};
// Then register it as a chainable hook
registerChainableHook(useRipple);
Now you can use it in a chain:
with(button)
.useEvents({ click: onClick })
.useRipple({ color: '#fff', duration: 400 })
.useClasses({ active: isActive });
This extensibility allows you to create a fluent, readable API customized for your project's needs.
These patterns help build more sophisticated UIs by connecting components and controlling scope.
HookTML includes a tiny, built-in reactive system inspired by signals:
const count = signal(0);
// read
console.log(count.value);
// write
count.value += 1;
Use useEffect()
to react to changes:
useEffect(() => {
display.textContent = `${count.value}`;
}, [count]);
Or use the more declarative useText()
hook:
useText(display, () => `${count.value}`, [count]);
This callback runs anytime count.value
changes, without re-rendering the component.
HookTML deliberately uses signals rather than a React-style useState
hook. This is a conscious design choice:
- No render cycles: Signals directly update the DOM without requiring re-rendering components
- Fine-grained reactivity: Only the effects that depend on a specific signal are re-run
- Explicit updates: The
.value
property makes it clear when you're reading or writing to reactive state - Primitive-oriented: Signals work as independent primitives that can be shared easily between hooks and components
While React's useState
is optimized for component re-rendering, signals are optimized for direct DOM updates, making them a better fit for HookTML's HTML-first approach.
Computed signals are reactive values that automatically derive from other signals. They update whenever their dependencies change, eliminating the need for manual synchronization:
const todos = signal([]);
// Computed signals automatically track dependencies
const totalTodos = computed(() => todos.value.length);
const completedTodos = computed(() => todos.value.filter(t => t.completed).length);
const completionPercentage = computed(() => {
const total = totalTodos.value;
if (total === 0) return 0;
return Math.round((completedTodos.value / total) * 100);
});
// Use computed signals just like regular signals
useText(statusEl, () => `${completedTodos.value}/${totalTodos.value} (${completionPercentage.value}%)`, [completionPercentage]);
- Automatic dependency tracking - No need to manually specify what each computed depends on
- Lazy evaluation - Only recomputes when accessed and dependencies have changed
- Efficient updates - Prevents unnecessary recalculations and cascade updates
- Clean separation - Keeps derived state logic separate from UI updates
Computed signals can depend on other computed signals, creating sophisticated reactive chains:
const users = signal([]);
const selectedUserId = signal(null);
// Chain computed signals for complex derivations
const selectedUser = computed(() =>
users.value.find(u => u.id === selectedUserId.value)
);
const userPermissions = computed(() =>
selectedUser.value?.permissions || []
);
const canEdit = computed(() =>
userPermissions.value.includes('edit')
);
const canDelete = computed(() =>
userPermissions.value.includes('delete') && selectedUser.value?.status === 'active'
);
// UI automatically updates when any dependency changes
useEffect(() => {
editBtn.disabled = !canEdit.value;
deleteBtn.disabled = !canDelete.value;
}, [canEdit, canDelete]);
Computed signals work seamlessly with HookTML's component model:
export const TodoStats = (el, props) => {
const { total, completed, percentage } = props.children;
// Computed signals eliminate manual state synchronization
useText(total, () => totalTodos.value, [totalTodos]);
useText(completed, () => completedTodos.value, [completedTodos]);
useText(percentage, () => `${completionPercentage.value}%`, [completionPercentage]);
};
This pattern is especially powerful for complex UIs where multiple components need to react to the same derived data, as computed signals ensure consistency without manual coordination.
When components need to talk to each other, you can return a context
object:
export const Dialog = (el, props) => {
const open = () => el.removeAttribute("hidden");
const close = () => el.setAttribute("hidden", "");
return {
context: { open, close }
};
};
Other components can access this context:
const dialog = el.closest(".Dialog")?.component?.context;
dialog?.open();
For more precise child selection, use useChildren(el, prefix)
:
export const useToggle = (el, props) => {
const children = useChildren(el, "toggle");
const { button, content } = children;
useEvents(button, {
click: () => {
content.toggleAttribute("hidden");
}
});
};
<section use-toggle>
<button toggle-button>Toggle</button>
<div toggle-content hidden>Hidden content</div>
</section>
The useChildren
helper always returns both singular and plural keys, regardless of how many elements are found:
// With multiple tabs, you get both access patterns
const children = useChildren(el, "tab");
const { tab, tabs } = children;
// Singular: work with the first tab
tab.setAttribute("aria-selected", "true");
// Plural: work with all tabs
tabs.forEach((tab, index) => {
tab.setAttribute("tabindex", index === 0 ? "0" : "-1");
});
This consistent API eliminates the need for conditional checks and lets you choose the most appropriate access pattern for your use case.
For more readable, declarative code, use the with()
helper:
export const useTooltip = (el, { value }) => {
const show = () => { /* ... */ };
const hide = () => { /* ... */ };
return with(el)
.useEvents({ mouseenter: show, mouseleave: hide })
.useAttributes({ "aria-label": value })
.useClasses({
"tooltip-visible": true,
"text-sm": true
})
.cleanup();
};
Here are some common UI patterns implemented with HookTML:
<div class="Tabs">
<div tabs-list role="tablist">
<button tabs-tab="tab1" aria-selected="true">Tab 1</button>
<button tabs-tab="tab2">Tab 2</button>
<button tabs-tab="tab3">Tab 3</button>
</div>
<div tabs-panel="tab1">Content 1</div>
<div tabs-panel="tab2" hidden>Content 2</div>
<div tabs-panel="tab3" hidden>Content 3</div>
</div>
export const Tabs = (el, props) => {
const { list, tabs, panels } = props.children;
const activeTab = signal('tab1');
// Computed signal for tab state - automatically updates when activeTab changes
const tabStates = computed(() =>
tabs.map(tabEl => ({
element: tabEl,
id: tabEl.getAttribute('tabs-tab'),
isActive: tabEl.getAttribute('tabs-tab') === activeTab.value
}))
);
useEffect(() => {
// Update tab selection using computed state
tabStates.value.forEach(({ element, isActive }) => {
element.setAttribute('aria-selected', isActive);
});
// Update panel visibility
panels.forEach((panelEl) => {
const panelId = panelEl.getAttribute('tabs-panel');
panelEl.hidden = panelId !== activeTab.value;
});
}, [tabStates]);
// Register click handlers for all tabs
tabs.forEach((tabEl) => {
useEvents(tabEl, {
click: () => {
const tabId = tabEl.getAttribute('tabs-tab');
activeTab.value = tabId;
}
});
});
};
<div class="Modal" modal-open="false">
<div modal-backdrop></div>
<div modal-container role="dialog">
<header modal-header>Title</header>
<div modal-body>Content</div>
<footer modal-footer>
<button modal-close>Close</button>
</footer>
</div>
</div>
export const Modal = (el, props) => {
const { backdrop, container, close } = props.children;
const open = () => {
el.setAttribute('modal-open', 'true');
document.body.style.overflow = 'hidden';
};
const hide = () => {
el.setAttribute('modal-open', 'false');
document.body.style.overflow = '';
};
useEvents(close, { click: hide });
useEvents(backdrop, { click: hide });
// Handle escape key
useEvents(document, {
keydown: (e) => {
if (e.key === 'Escape' && el.getAttribute('modal-open') === 'true') {
hide();
}
}
});
return {
cleanup: () => {
document.body.style.overflow = '';
},
context: { open, hide }
};
};
Modal.styles = `
&[modal-open="false"] {
display: none;
}
&[modal-open="true"] {
display: block;
}
[modal-backdrop] {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
}
[modal-container] {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 1rem;
border-radius: 4px;
max-width: 500px;
width: 100%;
}
`;
HookTML works well with server-rendered applications, without conflicting with other libraries.
HookTML scans the DOM when started and observes mutations to initialize new elements:
// On page load
import { start } from 'hooktml';
start();
// No need to reinitialize after DOM updates!
With script tag:
<script src="https://unpkg.com/hooktml@latest/dist/hooktml.min.js"></script>
<script>
// On page load
const { start } = HookTML;
start();
// No need to reinitialize after DOM updates!
</script>
HookTML pairs well with:
- Rails with Turbo - behavior persists through page navigations
- Laravel - enhance Blade templates with interactive behavior
- htmx - add client behaviors alongside htmx's server interactions
- Unpoly - complement Unpoly's layer and form enhancements
- Any server-rendered HTML - including PHP, Django, or static sites
HookTML listens to DOM mutations using MutationObserver
. This ensures behavior is attached automatically when:
- New elements are added (e.g. via AJAX, htmx, or Hotwire)
- Attributes change (e.g. adding/removing
use-*
,class
, ordata-component
) - Elements are removed (so cleanup functions run)
There's no need to reinitialize manually after partial DOM updates — HookTML keeps everything in sync.
HookTML brings behavior to your HTML in a declarative, composable way — no rendering layers, no virtual DOMs, no framework baggage.
- To enhance static HTML with dynamic behavior — without losing control of your markup
- To support composable, functional hooks over class-based controllers
- To keep behavior close to structure using HTML-first conventions
- To offer convention over configuration, inspired by Rails
- To work seamlessly with server-rendered apps, including Rails, Laravel, Hotwire, htmx, WordPress, and more
- ❌ Not a rendering library — it doesn't manage or diff your DOM
- ❌ Not a reactive framework — signals are minimal and scoped
- ❌ Not designed for large-scale app state or routing
- ❌ Not intended to replace tools like React, Vue, or Svelte — it fills a different niche
- Behavior is opt-in, bound declaratively via class or attributes
- Components don't re-render — they initialize once and clean up when removed
- Hooks focus on DOM behavior, not view logic
- Magic is embraced where it reduces boilerplate (e.g.,
use-*
,with(el)
), but the data flow remains readable and predictable
- Progressive enhancement of server-rendered views
- Reusable UI patterns like tooltips, tabs, modals, dropdowns
- Hotwire/htmx projects that need just a touch of JS behavior
- Teams who want the clarity of HTML with the composability of hooks