hooktml
Version:
A reactive HTML component library with hooks-based lifecycle management
1,282 lines (946 loc) โข 35.2 kB
Markdown
<p align="center">
<img src="https://raw.githubusercontent.com/shroy/hooktml/main/assets/logo.png" width="500" alt="HookTML logo" />
</p>
**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
## Why HookTML?
- ๐ **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
## ๐ Learn More
**[Why I Built HookTML: React Vibes, Stimulus Roots](https://dev.to/shroy/why-i-built-hooktml-react-vibes-stimulus-roots-3dde)** ๐
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.
## ๐ Try It Live
See HookTML in action with these interactive examples:
- **[Currency Converter](https://codepen.io/shroy/pen/bNVbjVP)** - Real-time reactive updates with signals
- **[Todo App](https://codepen.io/shroy/pen/VYvZBmB)** - Component communication and state management
- **[Modal Dialog](https://codepen.io/shroy/pen/qEOdmXe)** - Handling external triggers
- **[Tabs Component](https://codepen.io/shroy/pen/vENKaQN)** - Array support and element collections
- **[Counter](https://codepen.io/shroy/pen/myeJmXr)** - Purely using a hook
*All examples use the CDN - no build step required! Fork and experiment.*
## Quick Example
```html
<section class="Counter">
<button
counter-increment
use-tooltip="Click to increase the count"
>
Increment
</button>
<strong counter-display>0</strong>
</section>
```
```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.
## Table of Contents
1. [Installation & Setup](#installation--setup)
2. [Core Concepts](#core-concepts)
3. [Hooks](#hooks)
4. [Components](#components)
5. [Styling](#styling)
6. [API Reference](#api-reference)
7. [Advanced Patterns](#advanced-patterns)
8. [Examples & Recipes](#examples--recipes)
9. [Integration](#integration)
10. [Philosophy & Limitations](#philosophy--limitations)
## Installation & Setup
You can use HookTML directly in the browser via `<script type="module">` or install it with your preferred package manager.
### Using via CDN
```html
<script type="module">
import HookTML from 'https://unpkg.com/hooktml';
HookTML.start();
</script>
```
### Using via Script Tag
For projects that don't use ES modules, you can include HookTML as a global script:
```html
<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:
```html
<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>
```
### Using npm/yarn
```bash
npm install hooktml
# or
yarn add hooktml
```
```js
import HookTML from 'hooktml';
HookTML.start();
```
### Basic Configuration
```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
<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.
## Core Concepts
HookTML uses a simple mental model built around three key concepts:
### The HTML-first Approach
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 as the Building Blocks
Hooks are reusable behaviors that can be applied directly to any element using `use-*` attributes:
```html
<button use-tooltip="Click me">Save</button>
```
This declarative approach means behaviors are visible right in your markup - no hidden JavaScript wiring.
### Components as Organizational Units
When elements need to work together or share state, components let you group related behaviors:
```html
<section class="Dialog">
<header dialog-header>Title</header>
<button dialog-close>ร</button>
</section>
```
Or alternatively using the attribute syntax:
```html
<section use-component="Dialog">
<header dialog-header>Title</header>
<button dialog-close>ร</button>
</section>
```
Components automatically locate and interact with their children elements.
### When to Use Hooks vs. Components
- **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)
### The Declarative HTML Philosophy
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
Hooks are reusable behaviors applied to individual elements using `use-*` attributes.
### What Are Hooks and Why Use Them
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
### Using Built-in Hooks with `use-*` Attributes
Any attribute starting with `use-` automatically invokes a matching hook function:
```html
<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:
```html
<button
use-tooltip="Click to save"
tooltip-placement="top"
tooltip-color="blue"
>
Save
</button>
```
This becomes:
```js
props = {
value: "Click to save",
placement: "top",
color: "blue"
};
```
Values are automatically coerced:
```html
<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 } -->
```
### Accessing Children in Hooks
Hooks can also manage groups of related elements using the `useChildren` helper:
```html
<div use-toggle>
<button toggle-button>Toggle</button>
<div toggle-content hidden>Hidden content</div>
</div>
```
```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] }`
- **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
```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.
### Creating Custom Hooks
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
<input use-focus-ring />
```
### Composing Multiple Hooks
You can attach multiple hooks to a single element:
```html
<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:
```js
useTooltip(el, { value: "Click to submit" })
useAnalytics(el, { value: "form-submit" })
useFocusRing(el, {}) // โ no props
```
### Hook Lifecycle
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
Components are functions that group hooks and behaviors to coordinate multiple elements.
### What Are Components
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
### Components vs. Hooks: Key Differences
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"`)
- Hooks must be explicitly attached with `use-*` attributes
2. **Child Element Access**:
- Components automatically collect all children with matching prefixed attributes (`dialog-header`) into `props.children`
- Hooks must explicitly call `useChildren()` 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.
### Creating and Registering Components
Components are bound to elements using either a class name or a `use-component` attribute:
```html
<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):
```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.
### Accessing Children Elements
Child elements are auto-bound using lowercase attributes prefixed with the component name:
```html
<section class="Dialog">
<header dialog-header>Title</header>
<div dialog-body>Content</div>
<footer dialog-footer>Actions</footer>
</section>
```
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();
```
### Component Props and Attributes
To pass props into a component, use custom attributes prefixed with the component name:
```html
<section
class="Modal"
modal-open="true"
modal-size="lg"
></section>
```
Which becomes:
```js
props = {
open: true,
size: "lg"
};
```
### Component Lifecycle
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
}
};
```
## Styling
HookTML encourages writing CSS that mirrors your component structure, using attribute selectors for state.
### Component Styles
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:
```js
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:
```css
/* 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.
### Attribute-Based Styling
Rather than toggling classes, we recommend using attributes to reflect state and variants:
```js
el.setAttribute("button-loading", "");
```
```css
.Button[button-loading] {
opacity: 0.5;
pointer-events: none;
}
```
This is easier to debug in DevTools and avoids class name drift.
### Declarative Content Hooks
HookTML provides specialized hooks for updating content and styles declaratively in your JavaScript:
```js
// 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'
});
```
#### Array Support
Most utility hooks support arrays of elements with per-element logic using functions that receive both the element and its index:
```js
// 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
});
```
#### Manual Dependencies
When functions access signals (using `.value`), add them to the dependencies array for reactivity:
```js
// 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](#api-reference) section for complete details on these utility hooks.
### FOUC Prevention
HookTML automatically hides elements with `data-hooktml-cloak` until they're initialized:
```html
<section class="Dialog" data-hooktml-cloak></section>
```
```css
[data-hooktml-cloak] {
display: none !important;
}
```
The `data-hooktml-cloak` attribute is removed automatically once behavior is ready.
## API Reference
### Core Functions
| 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 |
### Utility Hooks
| 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 |
### Component Return Values
Components can return:
```js
// Simple cleanup function
return () => { ... };
// Or object with context and cleanup
return {
cleanup: () => { ... },
context: { ... }
};
```
### Chainable API
HookTML provides a chainable API for composing behaviors:
```js
with(el)
.useEvents({ click: onClick })
.useClasses({ active: isActive })
.useAttributes({ "aria-expanded": isOpen })
.useText(() => `Hello ${firstName}`)
.cleanup();
```
### Chainable Hooks
For more readable, declarative code, use the `with()` helper:
```js
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();
};
```
#### Creating Your Own Chainable Hooks
You can extend the chainable API with your own hooks, making them available through the `with()` helper:
```js
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:
```js
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.
## Advanced Patterns
These patterns help build more sophisticated UIs by connecting components and controlling scope.
### Reactivity with Signals
HookTML includes a tiny, built-in reactive system inspired by signals:
```js
const count = signal(0);
// read
console.log(count.value);
// write
count.value += 1;
```
Use `useEffect()` to react to changes:
```js
useEffect(() => {
display.textContent = `${count.value}`;
}, [count]);
```
Or use the more declarative `useText()` hook:
```js
useText(display, () => `${count.value}`, [count]);
```
This callback runs anytime `count.value` changes, without re-rendering the component.
#### Why Signals Instead of useState
HookTML deliberately uses signals rather than a React-style `useState` hook. This is a conscious design choice:
1. **No render cycles**: Signals directly update the DOM without requiring re-rendering components
2. **Fine-grained reactivity**: Only the effects that depend on a specific signal are re-run
3. **Explicit updates**: The `.value` property makes it clear when you're reading or writing to reactive state
4. **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
Computed signals are reactive values that automatically derive from other signals. They update whenever their dependencies change, eliminating the need for manual synchronization:
```js
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]);
```
#### Benefits of Computed Signals
1. **Automatic dependency tracking** - No need to manually specify what each computed depends on
2. **Lazy evaluation** - Only recomputes when accessed and dependencies have changed
3. **Efficient updates** - Prevents unnecessary recalculations and cascade updates
4. **Clean separation** - Keeps derived state logic separate from UI updates
#### Advanced Computed Patterns
Computed signals can depend on other computed signals, creating sophisticated reactive chains:
```js
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 in Components
Computed signals work seamlessly with HookTML's component model:
```js
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.
### Component Communication
When components need to talk to each other, you can return a `context` object:
```js
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:
```js
const dialog = el.closest(".Dialog")?.component?.context;
dialog?.open();
```
### Scoped Queries
For more precise child selection, use `useChildren(el, prefix)`:
```js
export const useToggle = (el, props) => {
const children = useChildren(el, "toggle");
const { button, content } = children;
useEvents(button, {
click: () => {
content.toggleAttribute("hidden");
}
});
};
```
```html
<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:
```js
// 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.
### Chainable Hooks
For more readable, declarative code, use the `with()` helper:
```js
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();
};
```
## Examples & Recipes
Here are some common UI patterns implemented with HookTML:
### Tabs Component
```html
<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>
```
```js
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;
}
});
});
};
```
### Modal Dialog
```html
<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>
```
```js
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%;
}
`;
```
## Integration
HookTML works well with server-rendered applications, without conflicting with other libraries.
### Auto Initialization
HookTML scans the DOM when started and observes mutations to initialize new elements:
```js
// On page load
import { start } from 'hooktml';
start();
// No need to reinitialize after DOM updates!
```
**With script tag:**
```html
<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>
```
### Working with Server Frameworks
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
### Mutation Observation
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`, or `data-component`)
* Elements are removed (so cleanup functions run)
There's no need to reinitialize manually after partial DOM updates โ HookTML keeps everything in sync.
## Philosophy & Limitations
HookTML brings behavior to your HTML in a declarative, composable way โ no rendering layers, no virtual DOMs, no framework baggage.
### โ
Why HookTML Exists
* 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
### โ ๏ธ What HookTML Is *Not*
* โ 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
### ๐ง Design Tradeoffs
* 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
### Ideal Use Cases
* 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