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

@cujuju/solidjs-hooks

v2.1.0

Published

Generic SolidJS hooks — click-outside, escape-key, hotkeys, localStorage-backed signals, media queries, observers, debounce, async status, after-paint scheduling, outside-scroll dismiss. Zero deps.

Readme

@cujuju/solidjs-hooks

Generic SolidJS utility hooks — zero dependencies, accessor-style APIs.

Install

pnpm add @cujuju/solidjs-hooks

Hooks

createClickOutside(isInside, handler, options?)

Fires handler on pointerdown outside the floating UI's interactive surface.

The surface is described by a predicate (target: Node) => boolean. The companion helper contains(...) covers the common ref-based cases:

import { createClickOutside, contains } from '@cujuju/solidjs-hooks';

let menuEl: HTMLDivElement | undefined;
const [open, setOpen] = createSignal(false);

// Single ref
createClickOutside(contains(() => menuEl), () => setOpen(false), { enabled: open });

// Multi-ref (button trigger + portaled panel both count as "inside")
createClickOutside(contains(() => [buttonEl, panelEl]), close, { enabled: open });

// Selector-based (e.g., portal-rendered subtree)
createClickOutside((t) => (t as Element).closest('[data-flyout]') !== null, close);

Behavior:

  • Listens on pointerdown (capture phase) — covers mouse, touch, and pen with one trigger. Capture phase means a child calling e.stopPropagation() cannot silently break the hook.
  • Suppresses events whose timeStamp predates listener attachment — the gesture that opened the floating UI (e.g., a contextmenu or click) cannot immediately close it. Replaces the per-callsite setTimeout defenses common in hand-rolled outside-click code.
  • options.enabled gate is checked on every event; safe to leave the listener attached for the lifetime of the component.

createEscapeKey(handler, options?)

Fires on Escape keydown.

createEscapeKey(() => setOpen(false), { enabled: open });

createEscapeOwner(options)

One Escape-owner stack for the whole app, held on globalThis under a Symbol.for key so separately bundled copies share it. Stacked surfaces push in show order; only the topmost one is dismissed, and it consumes the key (preventDefault + stopPropagation) so the surfaces below and any enclosing handler never see it. Use this instead of createEscapeKey for anything dismissible that can be stacked — popovers, flyouts, pickers.

const { isTop } = createEscapeOwner({
  open,
  onDismiss: () => setOpen(false),
  // Optional. An Escape raised inside an owned node reaches that node's own handlers FIRST
  // (native and Solid-delegated), so a nested inline editor keeps its Escape by calling
  // `preventDefault`. Omit and it is always consumed. Escapes from anywhere else are consumed
  // in the capture phase.
  owns: () => [panelEl, anchorEl],
});

// Gate other document-level keys on ownership too, so a surface underneath stays inert.
document.addEventListener('keydown', (e) => {
  if (!isTop()) return;
  // …
});

The entry is pushed on the open() false→true edge and popped on the true→false edge or on unmount — every path out. Re-runs of open that do not change its value keep show order.

For an Escape raised inside an owned node, the owner dismisses from that node's slot in Solid's delegated walk: descendant handlers go first, and an enclosing onKeyDown never sees a consumed key. A handler inside that calls only stopPropagation also keeps the surface open, but the key is left unconsumed (defaultPrevented stays false) — call preventDefault too.

This couples to Solid's $$<event> delegation convention: the owner chains itself into an owned node's $$keydown slot on first use and restores it on release. If the slot is reassigned while open (spread props), a document-bubble fallback still dismisses, but an enclosing delegated onKeyDown then sees the key first.

transparent: true is for hint surfaces (tooltips): the entry is transparent to other entries' isTop(), but still owns Escape when on top. An entry reports isTop() when no non-transparent entry is above it. So a tooltip shown over an open picker hides on Escape, and the picker's arrow keys keep working meanwhile. Default false.

A refused dismissal (the consumer leaves open true after onDismiss) keeps the entry on top: the key is still consumed, isTop() stays true, and the next Escape asks again. Residual: a consumer that refuses forever makes Escape a no-op for every layer below it — that is the consumer's bug, not the stack's.

isEscapeDismissing() is true while a dismissal is running, including any refocus it causes synchronously. The window is synchronous only: a refocus deferred to a microtask or frame falls outside it. Focus-out logic must ignore focus moves seen inside it — Escape dismisses ONE layer, and the layer above hands focus back on its way out.

createHotkey(combo, handler, options?)

Keyboard shortcuts. Combo syntax: modifiers (ctrl, shift, alt, meta) separated by +, then the key. Case-insensitive. cmd/command alias meta; option aliases alt. Modifier-only combos never match.

createHotkey('ctrl+k', () => openSearch());
createHotkey('shift+?', () => showHelp());
createHotkey('escape', () => close(), { enabled: () => modalOpen() });
createHotkey('up', () => focusPrev());          // alias for arrowup
createHotkey('shift+plus', () => zoomIn());     // 'plus' alias — '+' is the combo separator

Key aliases (additive — direct names like 'arrowup' / 'enter' continue to work):

| Alias | Resolves to (KeyboardEvent.key) | |---|---| | up / down / left / right | ArrowUp / ArrowDown / ArrowLeft / ArrowRight | | return | Enter | | esc | Escape | | space | ' ' (single space character) | | plus | + |

createLocalStorage(key, defaultValue)

Reactive JSON-serialized localStorage signal. Same shape as createSignal.

const [theme, setTheme] = createLocalStorage('theme', 'dark');
setTheme('light');

set(undefined) — or a value JSON.stringify turns into undefined, such as a symbol or a function — removes the key, so the next load falls back to defaultValue. A BigInt or circular structure throws out of set instead. null serializes to "null" and round-trips.

createPersistedSet<T>(key, options?)

Set<T> backed by localStorage. Defaults to Set<string>.

const expanded = createPersistedSet<string>('myapp:expanded');
expanded.toggle('panel-1');
expanded.set(); // accessor — reactive

For non-string values, provide serializers:

const ids = createPersistedSet<number>('myapp:ids', { serialize: String, deserialize: Number });

createPersistedMap<K, V>(key, options?)

Map<K, V> backed by localStorage.

const prefs = createPersistedMap<string, string>('myapp:prefs');
prefs.set('view', 'compact');
prefs.get('view'); // 'compact'

createMediaQuery(query)

Reactive media query matcher.

const isWide = createMediaQuery('(min-width: 768px)');
<Show when={isWide()}>Wide layout</Show>

createResizeObserver(elAccessor, handler)

Observes element size changes.

createResizeObserver(() => containerEl, (entry) => {
  setWidth(entry.contentRect.width);
});

createIntersectionObserver(elAccessor, handler, options?)

Observes viewport intersection.

createIntersectionObserver(() => targetEl, (entry) => {
  if (entry.isIntersecting) loadMore();
}, { threshold: 0.5 });

createDebounce(source, ms)

Returns a debounced accessor. Rapid source changes reset the delay.

const [query, setQuery] = createSignal('');
const debouncedQuery = createDebounce(query, 300);
createEffect(() => search(debouncedQuery()));

createDebouncedCallback(fn, ms)

Wraps a callback so repeated calls collapse to one invocation after ms ms of stillness. Pending calls are cancelled on dispose. isPending is a reactive accessor — true between a call() and the firing of fn (or the intervening cancel() / flush()); useful for "saving…" indicators.

const { call, cancel, flush, isPending } = createDebouncedCallback(saveToServer, 3000);
textarea.addEventListener('input', (e) => call(e.target.value));
// In JSX:
<Show when={isPending()}><span class="text-muted">Saving…</span></Show>

isPending flips back to false even if fn throws — it describes scheduling, not the in-flight call.

createDocumentVisibility()

Reactive document.visibilityState.

const visible = createDocumentVisibility();
createEffect(() => {
  if (visible() === 'hidden') pauseUpdates();
});

createAsyncStatus(fn, options?)

State machine for async actions with timed auto-reset: idle → loading → done/error → idle. Useful for buttons that flash "done" or "error" briefly.

const { status, run } = createAsyncStatus(async () => {
  return await saveData();
}, { resetMs: 2000 });

<button
  onClick={() => run()}
  disabled={status() === 'loading'}
>
  {status() === 'loading' ? 'Saving...' :
   status() === 'done' ? 'Saved!' :
   status() === 'error' ? 'Error' :
   'Save'}
</button>

createAfterPaint()

Returns schedule(fn) that runs fn on the next animation frame. A second schedule() call before the first fires cancels the pending frame (supersede). Component cleanup cancels any still-pending frame.

Use inside createEffect / onMount when you need post-DOM-flush measurement: popover positioning, focus-after-mount, scroll-indicator update, ResizeObserver-driven reposition. Replaces bare requestAnimationFrame(fn), which leaks a pending callback past component disposal.

const afterPaint = createAfterPaint();
createEffect(() => {
  if (open()) afterPaint(clampToViewport);
});

createOutsideScrollDismiss(getOpen, getPanelEl, onDismiss, shouldSuppress?)

Dismiss a popover/flyout when the page scrolls outside it. Anchored panels desynchronize from their anchor when the page scrolls; closing on scroll is the conventional fix.

Scrolls whose target lives inside getPanelEl() are ignored (so a scrollable list inside the panel can wheel without dismissing). shouldSuppress extends the in-panel filter to descendant surfaces that live OUTSIDE panelEl in the DOM (Portal'd popovers, modal dialogs) — pass a predicate matching your own descendant convention.

capture: true is used so non-window scroll containers also dismiss; the listener is gated on getOpen() so closed panels pay zero per-frame cost.

const [open, setOpen] = createSignal(false);
let panelEl: HTMLDivElement | undefined;
createOutsideScrollDismiss(open, () => panelEl, () => setOpen(false));

Convention: accessor style

Element-tracking hooks (createResizeObserver, createIntersectionObserver, and the contains() helper for createClickOutside) take an element accessor (() => HTMLElement | undefined), not a returned ref. The accessor is re-read on every callback, so refs that mount later (e.g., portaled panels) are picked up automatically.

For reactive gating, use options.enabled rather than returning undefined from the accessor:

createClickOutside(contains(() => menuEl), close, { enabled: open });

2.0.0 migration

All hooks renamed from use* to create* to match SolidJS idiomatic convention (createSignal, createMemo, solid-primitives library). Drop-in rename:

| Old (1.x) | New (2.x) | |---|---| | useClickOutside | createClickOutside | | useEscapeKey | createEscapeKey | | useHotkey | createHotkey | | useLocalStorage | createLocalStorage | | useMediaQuery | createMediaQuery | | useResizeObserver | createResizeObserver | | useIntersectionObserver | createIntersectionObserver | | useDebounce | createDebounce | | useDebouncedCallback | createDebouncedCallback | | useDocumentVisibility | createDocumentVisibility | | useAsyncStatus | createAsyncStatus | | usePersistedSet | createPersistedSet | | usePersistedMap | createPersistedMap |

Option / return interface type names follow the same pattern: UseHotkeyOptions → CreateHotkeyOptions, etc.

The contains utility is not renamed (it isn't a hook).

License

MIT