Skip to content

Latest commit

 

History

99 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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 stickjs

ESM / 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.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

Rebinding

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

About

Bare minimum UI component and declarative library.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages