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 ">
Script tag (put before your custom-handler <script>):
< script src ="stick.js "> </ script >
npm:
ESM / CommonJS:
import Stick from 'stickjs' ;
const Stick = require ( 'stickjs' ) ;
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
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")
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}}
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)
Handler
Effect
show
target.hidden = false
hide
target.hidden = true
toggle
Toggle target.hidden
Handler
Effect
add-class
target.classList.add(param)
remove-class
target.classList.remove(param)
toggle-class
target.classList.toggle(param)
Handler
Effect
set-text
target.textContent = param
set-html
target.innerHTML = param
set-value
target.value = param
clear
target.textContent = ""
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
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()
Handler
Effect
submit
Submit closest <form>
reset
Reset closest <form>
Handler
Effect
prevent
event.preventDefault()
stop
event.stopPropagation()
dispatch / emit
target.dispatchEvent(new CustomEvent(param, { bubbles: true, detail: { source: el } }))
Handler
Effect
remove
target.remove()
disable
target.disabled = true
enable
target.disabled = false
Handler
Effect
check
target.checked = true
uncheck
target.checked = false
toggle-check
Toggle target.checked
Handler
Effect
increment
Add param (default 1) to target value or textContent
decrement
Subtract param (default 1) from target value or textContent
Handler
Effect
set-data
target.dataset[key] = val — param: "key:value"
Handler
Effect
show-modal
target.showModal() — open native <dialog> as modal
close-modal
target.close(param?) — close native <dialog>
Handler
Effect
store
localStorage.setItem(key, val) — param: "key:value"
restore
localStorage.getItem(param) → set target .value or .textContent
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
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-*.
Handler
Effect
log
console.log('[Stick]', param)
alert
window.alert(param)
<!-- 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 >
Stick . version // "3.0.5"
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 element
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 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.
Open test/stick.test.html in a browser. Covers 170+ assertions across all handlers, modifiers, and edge cases.
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"
GPL-3.0 © 2011–2026 Kaique Silva