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

@sigx/gestures

v0.2.4

Published

Gesture system for SignalX - declarative composables for tap, pan, pinch, swipe, long press

Downloads

21

Readme

@sigx/gestures

Declarative, frame-locked gesture and animation primitives for SignalX on Lynx. Touch handlers, drag/swipe components, and animation linkage all run on the platform's main UI thread — your gestures track the finger at the display refresh rate even when the JS thread is busy fetching, parsing, or re-rendering.

Features

  • Built-in gesture components<Pressable>, <Draggable>, <Swipeable> — drop in for instant 60/120 fps interactions, no worklet plumbing in user code.
  • Main-Thread Scripting under the hood — touch handlers, transform updates, and visual feedback run on Lynx's main thread (Lepus) so gestures don't block on your background JS.
  • Background-thread composablesuseTap, useLongPress, usePan, usePinch, useSwipe, useRotation, useFling, usePanResponder, and a useGesture composer with simultaneous / exclusive / sequential relations.
  • Composition utilitiesmergeHandlers, gesture composers, render-prop slots for swipe-to-reveal actions.

Cross-thread primitive moved + renamed. useSharedValue, SharedValue (formerly useAnimatedValue / AnimatedValue), and useAnimatedStyle were promoted to @sigx/lynx in 0.3.0 — they have no gesture coupling and now live next to MainThreadRef (their base class). The primitive was renamed in Phase 2.8 to reflect that it's a general MT/BG bridge — animation is one customer; gestures and scroll are equally first-class. The old useAnimatedValue / AnimatedValue import paths still work via deprecated re-exports for one minor cycle; please import useSharedValue / SharedValue from @sigx/lynx directly in new code.

Installation

npm install @sigx/gestures

Requires @sigx/lynx as a peer dependency. The build pipeline (@sigx/lynx-plugin) handles the 'main thread' worklet transform automatically.

Quick start

import { signal, component, useSharedValue } from '@sigx/lynx';
import { Pressable, Draggable, Swipeable } from '@sigx/gestures';

const App = component(() => {
  const taps = signal(0);
  const dragX = useSharedValue(0);

  return () => (
    <view>
      {/* Tap with instant visual feedback */}
      <Pressable
        pressedOpacity={0.5}
        pressedScale={0.95}
        onPress={() => { taps.value++; }}
        style={{ width: '100px', height: '100px', backgroundColor: '#3b82f6' }}
      />

      {/* Drag at native frame rate; observe position on BG */}
      <Draggable
        translateX={dragX}
        snapBack
        onDragEnd={(e) => console.log('released at', e.x, e.y)}
        style={{ width: '90px', height: '90px', backgroundColor: '#a855f7' }}
      />
      <text>BG sees x = {dragX.value}</text>

      {/* Swipe-to-reveal */}
      <Swipeable
        rightActions={() => <view><text>Delete</text></view>}
        onSwipeOpen={(e) => console.log('opened', e.side)}
      >
        <view><text>Swipe me</text></view>
      </Swipeable>
    </view>
  );
});

Why this exists — the architecture

The two-thread model

Lynx runs your app on two JS contexts:

  • Background (BG) thread — your component code, signals, effects, fetch/parse, JSX renders.
  • Main (MT) thread — the renderer's commit thread, where native draw calls happen.

A naive touch handler runs on BG, mutates a signal, triggers a re-render, the renderer diffs styles, queues an op, the op crosses to MT, MT commits. Two thread crossings per touchmove, plus a JSX render and a JSON marshal. At 120 Hz touch input, that pipeline can't keep up — the cursor visibly lags the finger and chatters under GC pressure.

How @sigx/gestures solves it

Gesture components mark their touch handlers as 'main thread' worklets. The build pipeline extracts those handlers, ships them to MT once at startup, and Lynx native dispatches touch events directly to them. The handler then mutates a SharedValue (a thread-aware ref) and calls setStyleProperties on the bound element — all on the MT thread, zero crossings, no JSX render, no JSON.

Cross-thread observability

When you pass a SharedValue to a gesture component, the MT thread continuously writes to it. A bridge publishes those writes to the BG thread once per native flush (typically per frame), where they land in a signal-style mirror. Your effect(() => sv.value) re-runs reactively without injecting BG into the gesture hot path.

MT thread: tx.current.value = 50   ─┐
MT thread: setStyleProperties(...)  │  one event per flush
   ↓ __FlushElementTree              │  with [wvid, value] tuples
   ↓ flushAvBridgePublishes ────────┘
BG thread: signal value = 50  → effect re-runs, debounce, fetch, etc.

Built-in components

<Pressable>

Tap and long-press with optional visual feedback. The opacity and scale flash apply on MT inside the touchstart worklet, so feedback is visually instantaneous.

<Pressable
  pressedOpacity={0.5}
  pressedScale={0.95}
  longPressDuration={500}
  onPress={() => doThing()}
  onLongPress={() => doOtherThing()}
  style={{ ... }}
>
  <text>Press me</text>
</Pressable>

| Prop | Type | Default | Description | | -------------------- | --------- | ------- | -------------------------------------------------------- | | pressedOpacity | number | — | Opacity to apply on press, restored on release. | | pressedScale | number | — | scale() factor on press, restored on release. | | longPressDuration | number | 500 | ms to hold before onLongPress fires. | | maxDistance | number | 10 | Move threshold (px) above which press is cancelled. | | disabled | boolean | false | Suppresses both events and visual feedback. | | onPress | event | — | Fires on tap (touchend within maxDistance). | | onLongPress | event | — | Fires after longPressDuration if still pressed. |

<Draggable>

Pan-to-translate on the MT thread, with optional axis lock, bounds clamping, snap-back, and SharedValue exposure of the position.

const tx = useSharedValue(0);
const ty = useSharedValue(0);

<Draggable
  axis="both"
  threshold={4}
  snapBack
  minX={-100} maxX={100}
  translateX={tx} translateY={ty}
  onDragStart={(e) => console.log('start', e.x, e.y)}
  onDragEnd={(e) => console.log('end', e.x, e.y, 'velocity', e.vx, e.vy)}
>
  <view style={{ width: '90px', height: '90px', backgroundColor: '#a855f7' }} />
</Draggable>

| Prop | Type | Default | Description | | ------------------- | ------------------------------- | ------- | -------------------------------------------------------- | | axis | 'x' \| 'y' \| 'both' | 'both'| Restrict motion to one axis. | | threshold | number | 0 | Min distance (px) before recognition fires. | | snapBack | boolean | false | Animate back to origin on release. | | minX/maxX/minY/maxY | number | — | Clamp the translation range. | | translateX | SharedValue<number> | — | External SharedValue the worklet writes on every touchmove. | | translateY | SharedValue<number> | — | Same, for the Y axis. | | onDragStart | event { x, y } | — | Fires once per gesture after threshold is met. | | onDragEnd | event { x, y, vx, vy } | — | Fires on release; includes terminal velocity. |

<Swipeable>

Horizontal swipe-to-reveal with up to two action panels. Uses MTElementWrapper.animate() for the snap, so the easing curve runs on the native compositor.

<Swipeable
  leftActions={() => <view style={{ backgroundColor: '#22c55e' }}><text>Archive</text></view>}
  rightActions={() => <view style={{ backgroundColor: '#ef4444' }}><text>Delete</text></view>}
  onSwipeOpen={(e) => console.log('opened', e.side)}
  onSwipeClose={() => console.log('closed')}
>
  <view><text>Row content</text></view>
</Swipeable>

| Prop | Type | Default | Description | | --------------------- | -------------------------------- | ------- | -------------------------------------------------------- | | leftActionsWidth | number | 100 | Width (px) of the left reveal panel. | | rightActionsWidth | number | 100 | Width (px) of the right reveal panel. | | snapThreshold | number | 40 | Min translation before snapping to the open position. | | snapDuration | number | 200 | Snap animation duration (ms). | | leftActions | () => JSX | — | Render-prop for the left panel. | | rightActions | () => JSX | — | Render-prop for the right panel. | | onSwipeOpen | event { side: 'left' \| 'right' } | — | Fires when the row snaps open. | | onSwipeClose | event | — | Fires when the row snaps closed from an open position. |

<ScrollView>

MT-thread <scroll-view> wrapper that mirrors scroll position into a SharedValue. Pair with useAnimatedStyle for parallax / fade / scale effects driven by scroll — all running on MT with zero per-frame thread crossings.

import { useSharedValue, useAnimatedStyle, useMainThreadRef } from '@sigx/lynx';
import { ScrollView } from '@sigx/gestures';

const scrollY = useSharedValue(0);
const headerRef = useMainThreadRef<MainThread.Element | null>(null);

useAnimatedStyle(headerRef, scrollY, 'translateY', {
  inputRange: [0, 300], outputRange: [0, -150], extrapolate: 'clamp',
});

<ScrollView offsetY={scrollY}>
  <view main-thread:ref={headerRef}><image src={hero} /></view>
  <text>Body…</text>
  <text>Scroll: {scrollY.value.toFixed(0)}px</text>
</ScrollView>

| Prop | Type | Default | Description | | --------------------- | -------------------------------- | ------------ | -------------------------------------------------------- | | offsetY | SharedValue<number> | — | External SharedValue the worklet writes on every scroll. | | offsetX | SharedValue<number> | — | Same, for the horizontal axis. | | scroll-orientation | 'vertical' \| 'horizontal' | 'vertical' | Pass-through to <scroll-view>. | | class / style | string / object | — | Pass-through styling. |

The component handles the inline 'main thread' worklet, the SharedValue writes, and the __FlushElementTree() trigger internally. Users only see SharedValues.


Animation primitives

The cross-thread primitive — useSharedValue, SharedValue, useAnimatedStyle — lives in @sigx/lynx since 0.3.0. Import from @sigx/lynx directly:

useSharedValue<T>(initial) (from @sigx/lynx)

Allocates a thread-aware value: writeable on MT, reactively observable on BG.

import { useSharedValue } from '@sigx/lynx';

const tx = useSharedValue(0);

// MT (inside a 'main thread' worklet)
tx.current.value = 50;

// BG (in component body, effect, computed, JSX)
console.log(tx.value);
effect(() => console.log('tx is now', tx.value));

sv.current.value is the MT-side read/write path (the underlying MainThreadRef envelope). sv.value is the BG-side reactive read. Writes on BG are read-only (a dev warning fires); the canonical mutation path is the MT worklet.

The bridge coalesces writes per native flush — N MT mutations within one frame land as one BG event with N tuples.

useAnimatedStyle(elRef, sv, mapperName, params?) (from @sigx/lynx)

Bind an element's style to a SharedValue via a named mapper. The mapper runs on MT every flush where the SharedValue's value changed.

import { useMainThreadRef, useSharedValue, useAnimatedStyle } from '@sigx/lynx';

const tx = useSharedValue(0);
const ghostRef = useMainThreadRef<MainThread.Element | null>(null);

useAnimatedStyle(ghostRef, tx, 'translateX', { factor: 0.5 });
useAnimatedStyle(ghostRef, tx, 'opacity', { factor: -0.01, offset: 1 });

<Draggable translateX={tx} />
<view main-thread:ref={ghostRef} style={{ ... }} />

The ghost view tracks the draggable at half speed and fades as it moves — without a single thread crossing per frame.

Built-in mappers

translateX, translateY, scale, and opacity accept either a linear { factor, offset } shape or a range-mapping { inputRange, outputRange, extrapolate? } shape (see "Range mapping" below).

| Name | Linear param shape | Output | | ------------ | ------------------------------------------ | --------------------------------------------------- | | translateX | { factor?: number } | transform: translateX(value * factor)px | | translateY | { factor?: number } | transform: translateY(value * factor)px | | translate | { factorX?: number; factorY?: number } | transform: translate(v.x*fx, v.y*fy)px (2D SharedValue) | | scale | { offset?: number } | transform: scale(value + offset) | | opacity | { factor?: number; offset?: number } | opacity clamped to [0, 1] of value*f + o | | rotate | (none) | transform: rotate(value)deg |

When multiple bindings on the same element produce a transform, the parts concatenate in registration order. Other style keys merge; later registrations win on duplicate keys. Whenever any binding on an element ticks, all of its bindings re-run so partial outputs don't drop the unchanged-axis contribution.

Range mapping

translateX / translateY / scale / opacity also accept { inputRange, outputRange, extrapolate? } — handy for scroll-driven UIs:

const { y, onScroll } = useScrollViewOffset();
const headerRef = useMainThreadRef<MainThread.Element | null>(null);

// Parallax: scroll 0..300 → translateY 0..-150, clamped beyond.
useAnimatedStyle(headerRef, y, 'translateY', {
  inputRange: [0, 300], outputRange: [0, -150], extrapolate: 'clamp',
});

Multi-stop ranges (length ≥ 2) work — each segment is interpolated independently. extrapolate: 'clamp' (default) caps at the endpoints; 'identity' extends linearly using the slope of the nearest segment.

Custom mappers

You can register additional mappers from MT-side code:

// in a 'main thread'-marked module
import { registerMapper } from '@sigx/runtime-lynx-main';

registerMapper('skewX', (v) => ({ transform: `skewX(${v}deg)` }));

Then use the name from BG: useAnimatedStyle(elRef, sv, 'skewX'). The string name is what crosses the build pipeline; the function lives on MT.


Background-thread composables

For cases where you don't need MT-thread tracking (state machines that drive non-visual logic, gestures over scroll lists, or coordinating multiple recognizers at once), the package also ships background-thread recognizers exposing signal-based state.

| Composable | Returns | | ----------------- | ------------------------------------------------ | | useTap | tap state + handlers; onTap, onDoubleTap | | useLongPress | long-press detection | | usePan | drag distance / velocity | | usePinch | scale, focal point | | useSwipe | direction + distance | | useRotation | two-finger rotation in radians | | useFling | velocity-gated flick | | usePanResponder | RN-shape onStartShouldSet / onMove / etc. | | useGesture | composer (simultaneous / exclusive / sequential) |

const pan = usePan({
  onMove: (state) => console.log(state.dx, state.dy),
});

<view {...pan.handlers} />

These are simpler to compose and fully introspectable on BG, at the cost of a thread crossing per gesture event. For visual feedback (translate, scale, opacity), prefer the MT components above.


Performance notes

  • Avoid changing the gesture component's style prop on every render. A BG-side SET_STYLE op for the same element being dragged can clobber MT-side setStyleProperties writes. The framework guards this with shallow-equal in the style patcher, so structurally-stable inline styles (style={{ width: '90px', ... }}) are fine. Computed-per-render styles touching the dragged element are the case to watch.
  • Pass MT-locals through runOnBackground arguments, not through closure capture. The BG-bound function only sees what crossed the bridge — its parameter list. Capturing a let side = … declared inside the worklet body will fail at runtime with ReferenceError because BG never had side.
  • Per-SharedValue === diff coalescing means object-typed SharedValues (useSharedValue<{x,y}>) only publish on identity change, not on property mutation. Use scalar SharedValues and compose them, or use the translate mapper which takes a 2D value.

Testing

The package ships with two test layers:

  • Source-shape regex tests verify that 'main thread' directives, handler attribute spellings, and worklet captures are all in place. Fast; run as part of pnpm test.
  • MT end-to-end tests (pnpm --filter @sigx/gestures test:mt) actually run the SWC LEPUS transform on the live source, eval the resulting registerWorkletInternal calls into the upstream worklet runtime under vitest, and drive synthetic touches through the registered worklets — catching the class of bug where a refactor breaks the worklet pipeline silently.

Related

License

MIT