@ktfth/stickjs
v3.0.5
Published
Declarative behavior for HTML elements. Zero dependencies.
Downloads
69
Maintainers
Readme
Stick.js
Declarative behavior for HTML elements. Zero dependencies, ~220 lines of vanilla JS.
Drop in one <script> and annotate your HTML — no JavaScript glue required for the most common UI interactions.
<script src="stick.js"></script>
<button data-stick="click:toggle" data-stick-target="#menu">Menu</button>
<input data-stick="input:fetch:/api/search?q={{value}}"
data-stick-target="#results" data-stick-debounce="300">Install
Script tag (put before your custom-handler <script>):
<script src="stick.js"></script>npm:
npm install stickjsESM / CommonJS:
import Stick from 'stickjs';
const Stick = require('stickjs');Syntax
data-stick="event:handler:param"| Attribute | Purpose |
|-----------|---------|
| data-stick="event:handler:param" | Primary behavior |
| data-stick-2="event:handler:param" | Stack a 2nd behavior (up to data-stick-5) |
| data-stick-target="#sel" | Apply handler to another element |
| data-stick-target-2="#other" | Per-slot target for data-stick-2 (also -3, -4, -5) |
| data-stick-once | Remove listener after first fire |
| data-stick-debounce="300" | Debounce handler by N ms |
| data-stick-throttle="300" | Throttle handler by N ms |
| data-stick-confirm="message" | window.confirm() gate before handler |
| data-stick-key="Enter" | Only fire when event.key matches (comma-separated, e.g. "Enter,Escape") |
| data-stick-prevent | Always call event.preventDefault() before handler (e.g. on <form>) |
| data-stick-stop | Always call event.stopPropagation() before handler |
| data-stick-passive | Add event listener as passive (use for scroll/touchmove) |
| data-stick-capture | Add event listener in capture phase |
| data-stick-delegate=".item" | Event delegation — listen on el, fire only for matching descendant elements. Handler receives the matched child as el. |
| data-stick-method="POST" | HTTP method for fetch handler |
| data-stick-swap="beforeend" | Fetch insertion mode (default: innerHTML). Aliases: prepend = afterbegin, append = beforeend |
| data-stick-loading="Loading…" | Button label while fetch is in-flight |
| data-stick-headers='{"Authorization":"Bearer TOKEN"}' | Fetch headers (JSON string) |
| data-stick-json='{"key":"{{value}}"}' | JSON body for fetch POST (auto-sets Content-Type) |
| data-stick-error="#el" | Element to display fetch errors |
| data-stick-map="#sel:Field, …" | JSON field → DOM mapping for fetch-json handler |
| data-stick-then="handler:tgt:p, …" | Post-fetch action chain — executes sequentially after success |
| data-stick-accept="json" | Parse fetch response as JSON array (use with data-stick-template) |
| data-stick-template="#tpl" | <template> for JSON array rendering (with accept=json) |
| data-stick-state-loading="…" | State actions when fetch starts (e.g. "add-class:loading:#el, disable:#btn") |
| data-stick-state-done="…" | State actions when fetch succeeds |
| data-stick-state-error="…" | State actions when fetch fails |
Target selectors
data-stick-target accepts:
| Value | Resolves to |
|-------|------------|
| "#id" or any CSS selector | document.querySelector(sel) |
| "self" | The trigger element itself (explicit) |
| "siblings" | All sibling elements (same parent, excluding el) |
| "all:.cls" | All elements matching a CSS selector (querySelectorAll) |
| "next" | el.nextElementSibling |
| "prev" | el.previousElementSibling |
| "parent" | el.parentElement |
| "closest:form" | el.closest("form") |
Param interpolation
Tokens in param are resolved at event time from the trigger element:
| Token | Value |
|-------|-------|
| {{value}} | el.value |
| {{text}} | el.textContent |
| {{id}} | el.id |
| {{name}} | el.name |
| {{checked}} | "true" or "false" |
| {{index}} | Position among parent's children (0-based) |
| {{length}} | el.children.length |
| {{chars}} | el.value.length or textContent.length — character count |
| {{data-foo}} | el.dataset.foo |
| {{url:key}} | URLSearchParams value from the current page URL (e.g. {{url:q}} → ?q=value) |
| {{href}}, {{src}}, etc. | Any attribute via el.getAttribute(name) |
| {{#sel.prop}} | Cross-element: querySelector(sel).prop — e.g. {{#myInput.value}} |
Synthetic events
| Event | Trigger |
|-------|---------|
| ready | Fires immediately on bind — use to initialize state |
| watch | Fires immediately on bind, then on every attribute mutation of el (MutationObserver) |
| intersect | Element enters the viewport (IntersectionObserver) |
Built-in handlers
Visibility
| Handler | Effect |
|---------|--------|
| show | target.hidden = false |
| hide | target.hidden = true |
| toggle | Toggle target.hidden |
CSS classes
| Handler | Effect |
|---------|--------|
| add-class | target.classList.add(param) |
| remove-class | target.classList.remove(param) |
| toggle-class | target.classList.toggle(param) |
Content
| Handler | Effect |
|---------|--------|
| set-text | target.textContent = param |
| set-html | target.innerHTML = param |
| set-value | target.value = param |
| clear | target.textContent = "" |
Attributes & styles
| Handler | Effect |
|---------|--------|
| set-attr | target.setAttribute(name, val) — param: "name:value" |
| remove-attr | target.removeAttribute(param) |
| toggle-attr | Toggle attribute presence — param is attr name (e.g. "disabled", "open") |
| set-style | target.style[prop] = val — param: "property:value" |
Focus, scroll & selection
| Handler | Effect |
|---------|--------|
| focus | target.focus() |
| scroll-to | target.scrollIntoView({ behavior: 'smooth' }) |
| select | target.select() — select all text in an input/textarea |
Clipboard & navigation
| Handler | Effect |
|---------|--------|
| copy | navigator.clipboard.writeText(param \|\| el.textContent) |
| navigate | window.location.href = param |
| open | window.open(param, '_blank', 'noopener') |
| back | window.history.back() |
| forward | window.history.forward() |
| history-push | window.history.pushState({}, '', param) — update URL without reload |
| scroll-top | window.scrollTo({ top: 0, behavior: 'smooth' }) — back-to-top |
| reload | window.location.reload() |
| print | window.print() |
Forms
| Handler | Effect |
|---------|--------|
| submit | Submit closest <form> |
| reset | Reset closest <form> |
Events
| Handler | Effect |
|---------|--------|
| prevent | event.preventDefault() |
| stop | event.stopPropagation() |
| dispatch / emit | target.dispatchEvent(new CustomEvent(param, { bubbles: true, detail: { source: el } })) |
DOM
| Handler | Effect |
|---------|--------|
| remove | target.remove() |
| disable | target.disabled = true |
| enable | target.disabled = false |
Checkboxes
| Handler | Effect |
|---------|--------|
| check | target.checked = true |
| uncheck | target.checked = false |
| toggle-check | Toggle target.checked |
Counters
| Handler | Effect |
|---------|--------|
| increment | Add param (default 1) to target value or textContent |
| decrement | Subtract param (default 1) from target value or textContent |
Data attributes
| Handler | Effect |
|---------|--------|
| set-data | target.dataset[key] = val — param: "key:value" |
Native dialog
| Handler | Effect |
|---------|--------|
| show-modal | target.showModal() — open native <dialog> as modal |
| close-modal | target.close(param?) — close native <dialog> |
Storage
| Handler | Effect |
|---------|--------|
| store | localStorage.setItem(key, val) — param: "key:value" |
| restore | localStorage.getItem(param) → set target .value or .textContent |
Templates & animation
| Handler | Effect |
|---------|--------|
| clone-template | Clone <template> matched by param selector, resolve {{tokens}} from el, append to target. Auto-binds new elements. |
| animate | target.classList.add(param) — removes the class automatically after animationend |
| sort | Sort target.children by textContent (default) or param key (e.g. "data-price") — numeric-aware |
| count | Set target.textContent to count of elements matching CSS param, or target.children.length when empty |
Network
| Handler | Notes |
|---------|-------|
| fetch | Fetch param URL, inject response HTML into target. Supports data-stick-method, data-stick-swap, data-stick-loading, data-stick-headers, data-stick-json, data-stick-error, data-stick-accept, data-stick-template, data-stick-then, data-stick-state-*. |
| fetch-json | GET param URL, parse JSON, map fields to DOM elements via data-stick-map="#sel:Field, …". Supports data-stick-then, data-stick-state-*. |
Debug
| Handler | Effect |
|---------|--------|
| log | console.log('[Stick]', param) |
| alert | window.alert(param) |
Examples
<!-- Toggle menu open/closed -->
<button data-stick="click:toggle-class:open" data-stick-target="#menu">Menu</button>
<!-- Show panel on click, only once -->
<button data-stick="click:show" data-stick-target="#welcome" data-stick-once>
Show welcome
</button>
<!-- Search with debounce -->
<input data-stick="input:fetch:/api/search?q={{value}}"
data-stick-target="#results"
data-stick-debounce="300"
placeholder="Search…">
<!-- Fetch + append (load more) -->
<button data-stick="click:fetch:/api/posts?page={{data-page}}"
data-stick-target="#list"
data-stick-swap="beforeend"
data-stick-loading="Loading…"
data-page="2">Load more</button>
<!-- Confirm before destructive action -->
<button data-stick="click:fetch:/api/item/42"
data-stick-method="DELETE"
data-stick-target="parent"
data-stick-confirm="Delete this item?">Delete</button>
<!-- POST JSON -->
<button data-stick="click:fetch:/api/users"
data-stick-method="POST"
data-stick-json='{"name":"{{value}}"}'
data-stick-target="#result">Create user</button>
<!-- Lazy-load on scroll into view -->
<section data-stick="intersect:fetch:/api/widget"
data-stick-target="#widget-output"
data-stick-once>Loading widget…</section>
<!-- Stack behaviors with different targets -->
<button
data-stick="click:add-class:loading" data-stick-target="#submit-btn"
data-stick-2="click:show" data-stick-target-2="#spinner"
data-stick-3="click:fetch:/api/save" data-stick-target-3="#response">
Save
</button>
<!-- Dispatch event for other components to react -->
<li data-stick="click:emit:item-selected" data-id="42">Item 42</li>
<!-- Navigate to sibling (no ID needed) -->
<button data-stick="click:toggle" data-stick-target="next">Toggle next</button>
<div>This toggles</div>
<!-- Native dialog modal -->
<button data-stick="click:show-modal:" data-stick-target="#my-dialog">Open</button>
<dialog id="my-dialog">
<p>Hello from dialog</p>
<button data-stick="click:close-modal:" data-stick-target="closest:dialog">Close</button>
</dialog>
<!-- Submit form on Enter key -->
<input data-stick="keydown:submit:" data-stick-key="Enter">
<!-- Increment / decrement counter -->
<button data-stick="click:decrement:" data-stick-target="#count">−</button>
<span id="count">0</span>
<button data-stick="click:increment:" data-stick-target="#count">+</button>
<!-- Tab interface — no JS required -->
<nav>
<button class="tab active"
data-stick="click:add-class:active"
data-stick-2="click:remove-class:active" data-stick-target-2="siblings">Tab 1</button>
<button class="tab"
data-stick="click:add-class:active"
data-stick-2="click:remove-class:active" data-stick-target-2="siblings">Tab 2</button>
</nav>
<!-- Restore stored preference on page load -->
<select data-stick="ready:restore:theme-pref" data-stick-target="self"
data-stick-2="change:store:theme-pref:{{value}}">
<option>light</option><option>dark</option>
</select>
<!-- Animate on click -->
<button data-stick="click:animate:bounce" data-stick-target="#icon">Bounce</button>
<span id="icon">🎉</span>
<!-- clone-template with interpolation — add items from input, no JS needed -->
<template id="task-tpl">
<li>{{value}} <button data-stick="click:remove" data-stick-target="parent">×</button></li>
</template>
<input id="task-input" placeholder="New task"
data-stick="keydown:clone-template:#task-tpl"
data-stick-key="Enter"
data-stick-target="#task-list"
data-stick-2="keydown:set-value:"
data-stick-target-2="self">
<ul id="task-list"></ul>
<!-- Sort a list alphabetically -->
<button data-stick="click:sort" data-stick-target="#task-list">Sort A–Z</button>
<!-- Count items -->
<span data-stick="ready:count:.task" data-stick-target="self"></span>
<!-- Event delegation — single listener handles dynamic list items -->
<ul data-stick="click:remove" data-stick-target="parent" data-stick-delegate=".item">
<li class="item">Task A <button>×</button></li>
<li class="item">Task B <button>×</button></li>
</ul>
<!-- Back to top -->
<button data-stick="click:scroll-top">Back to top</button>
<!-- Pre-fill search from URL ?q= param -->
<input data-stick="ready:set-value:{{url:q}}" data-stick-target="self">
<!-- fetch-json: map API fields to multiple elements -->
<div data-stick="ready:fetch-json:/api/kpis"
data-stick-map="#total:Total, #pending:Pending, #pct:Percent">
</div>
<span id="total">–</span> | <span id="pending">–</span> | <span id="pct">–</span>
<!-- Cross-element interpolation: combine filters from 2+ inputs -->
<select id="region"><option>North</option><option>South</option></select>
<select id="status"><option>active</option><option>closed</option></select>
<button data-stick="click:fetch:/api/data?region={{#region.value}}&status={{#status.value}}"
data-stick-target="#results">Filter</button>
<!-- Fetch JSON array → template cloning -->
<template id="row-tpl">
<tr><td>{{name}}</td><td>{{score}}</td></tr>
</template>
<button data-stick="click:fetch:/api/users"
data-stick-accept="json"
data-stick-template="#row-tpl"
data-stick-target="#tbody">Load users</button>
<table><tbody id="tbody"></tbody></table>
<!-- Post-fetch then chain: close modal + refresh table -->
<form data-stick="submit:fetch:/api/save"
data-stick-method="POST"
data-stick-target="#feedback"
data-stick-then="close-modal:#dialog, fetch:#table:/api/list">
<input name="name">
<button type="submit">Save</button>
</form>
<!-- Fetch lifecycle state management -->
<button data-stick="click:fetch:/api/report"
data-stick-target="#output"
data-stick-state-loading="add-class:loading:#container, disable:#submit"
data-stick-state-done="remove-class:loading:#container, enable:#submit"
data-stick-state-error="add-class:error:#container"
id="submit">Generate</button>JavaScript API
Stick.version // "3.0.4"
Stick.add(name, fn) // register a handler — chainable
Stick.remove(name) // unregister a handler — chainable
Stick.use(plugin) // register a plugin — chainable
Stick.debug(true?) // enable verbose console logging — chainable
// plugin: fn(stick) | { install(stick) } | { 'name': fn, … }
Stick.unbind(el) // remove all listeners from el, clear data-stick-bound — chainable
// fn signature: (el, param, event, target) => void | Promise<void>
// el — trigger element (has data-stick)
// param — string after second colon, {{tokens}} already resolved
// event — DOM Event or synthetic { type: 'intersect', entry }
// target — resolved data-stick-target element, or el
Stick.handlers // frozen snapshot: { name: fn, … }
Stick.init(root?) // scan and bind [data-stick] under root — chainable
// default: document
Stick.observe(root?) // MutationObserver: auto-bind new [data-stick] elements
// called automatically on load; default: document.body
Stick.parse(value) // parse "event:handler:param" → { event, handler, param } | null
Stick.bind(el) // bind a single elementRebinding
Elements are marked data-stick-bound after binding to prevent double-bind. Remove it to force rebind:
el.removeAttribute('data-stick-bound');
Stick.bind(el);Script placement
<script src="stick.js"></script> ← here
<script>
Stick.add('my-handler', fn); ← register before DOMContentLoaded fires
</script>
<!-- data-stick elements anywhere in body -->stick.js queues Stick.init() on DOMContentLoaded, so any Stick.add() calls in subsequent <script> tags are picked up automatically.
Testing
Open test/stick.test.html in a browser. Covers 170+ assertions across all handlers, modifiers, and edge cases.
LLM-friendly
See llms.txt for a machine-readable reference optimised for LLM context windows.
| Intent | HTML |
|--------|------|
| "toggle sidebar on click" | data-stick="click:toggle" data-stick-target="#sidebar" |
| "search as you type, wait 300ms" | data-stick="input:fetch:/api/search?q={{value}}" data-stick-debounce="300" data-stick-target="#results" |
| "confirm before delete" | data-stick="click:fetch:/api/items/1" data-stick-method="DELETE" data-stick-confirm="Delete?" data-stick-target="parent" |
| "lazy-load when visible" | data-stick="intersect:fetch:/api/widget" data-stick-once data-stick-target="#out" |
| "copy link on click" | data-stick="click:copy:https://example.com" |
| "two behaviors, two targets" | data-stick="click:show" data-stick-target="#modal" data-stick-2="click:add-class:open" data-stick-target-2="#overlay" |
License
GPL-3.0 © 2011–2026 Kaique Silva
