npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ktfth/stickjs

v3.0.5

Published

Declarative behavior for HTML elements. Zero dependencies.

Downloads

69

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