Guide

Touch and gestures

nsh has three levels of touch handling. Presses (onPress and friends) cover buttons, rows and links, with press feedback drawn natively. Native gestures (pan, pinch, rotation) move native values on the UI thread, so a dragged card follows the finger even while JS is busy. Raw touches and the responder system (onTouchStart, onResponderMove, PanResponder) give you every finger position in JS for anything the other two cannot express, such as drawing.

guide-gestures.tsx Edit in Playground Open in new tab
import { mount, signal, Text, View } from "nsh";
import { createNativeValue } from "nsh/animated";
import { PanResponder } from "nsh/gesture";

const muted = "#57606a";

/** A card dragged on the UI thread: `pan` drives two native values. */
function DragCard() {
  const x = createNativeValue(0);
  const y = createNativeValue(0);
  const flings = signal(0);
  return (
    <View style={{ height: 150, borderRadius: 12, backgroundColor: "#eaeef2", alignItems: "center", justifyContent: "center", overflow: "hidden" }}>
      <View
        pan={{ x, y }}
        onPanEnd={(e) => {
          flings.update((n) => n + 1);
          // Spring home, starting with the release velocity.
          x.spring(0, { velocity: e.velocityX, stiffness: 200, damping: 16 });
          y.spring(0, { velocity: e.velocityY, stiffness: 200, damping: 16 });
        }}
        style={{
          width: 120,
          height: 80,
          borderRadius: 16,
          backgroundColor: "#8250df",
          alignItems: "center",
          justifyContent: "center",
          transform: [{ translateX: x }, { translateY: y }],
        }}
      >
        <Text style={{ color: "#ffffff", fontWeight: "700" }}>Drag me</Text>
        <Text style={{ color: "#ffffff", fontSize: 12 }}>released {flings()}×</Text>
      </View>
    </View>
  );
}

/** A dot moved with PanResponder: every move goes through JS. */
function ResponderDot() {
  const pos = signal({ x: 0, y: 0 });
  const state = signal("idle");
  let start = { x: 0, y: 0 };
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => {
      start = pos();
      state.set("dragging");
    },
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => state.set(`released, vx ${g.vx.toFixed(2)} pt/ms`),
    onPanResponderTerminate: () => state.set("taken over"),
  });
  return (
    <View style={{ gap: 6 }}>
      <View style={{ height: 110, borderRadius: 12, backgroundColor: "#eaeef2", overflow: "hidden" }}>
        <View
          {...responder.panHandlers}
          style={{
            position: "absolute",
            left: 150,
            top: 30,
            width: 50,
            height: 50,
            borderRadius: 25,
            backgroundColor: "#0969da",
            transform: [{ translateX: pos().x }, { translateY: pos().y }],
          }}
        />
      </View>
      <Text style={{ color: muted, fontSize: 13 }}>PanResponder: {state()}</Text>
    </View>
  );
}

function App() {
  const log = signal<string[]>([]);
  const add = (event: string) => log.update((l) => [event, ...l].slice(0, 4));
  const dot = signal(0);
  const under = signal(0);
  return (
    <View style={{ flex: 1, backgroundColor: "#f6f8fa", padding: 16, paddingTop: 24, gap: 14 }}>
      <View style={{ flexDirection: "row", gap: 10 }}>
        <View
          onPressIn={() => add("pressIn")}
          onPressOut={() => add("pressOut")}
          onPress={() => add("press")}
          onLongPress={() => add("longPress")}
          activeOpacity={0.5}
          style={{ flex: 1, padding: 14, borderRadius: 12, backgroundColor: "#0969da", alignItems: "center" }}
        >
          <Text style={{ color: "#ffffff", fontWeight: "600" }}>activeOpacity</Text>
        </View>
        <View
          onPress={() => add("underlay")}
          underlayColor="#ddf4ff"
          style={{ flex: 1, padding: 14, borderRadius: 12, backgroundColor: "#ffffff", borderWidth: 1, borderColor: "#d0d7de", alignItems: "center" }}
        >
          <Text style={{ fontWeight: "600" }}>underlayColor</Text>
        </View>
        <View
          onPress={() => add("active:")}
          class="flex-1 items-center rounded-xl bg-emerald-600 p-3.5 transition active:scale-95 active:bg-emerald-800"
        >
          <Text class="font-semibold text-white">active:</Text>
        </View>
      </View>
      <Text style={{ color: muted, fontSize: 13 }}>{log().join(" · ") || "Tap, hold, or slide off a button"}</Text>

      <View style={{ flexDirection: "row", alignItems: "center", gap: 16 }}>
        <View
          hitSlop={24}
          onPress={() => dot.update((n) => n + 1)}
          style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: "#cf222e", margin: 12 }}
        />
        <Text style={{ flex: 1 }}>hitSlop 24: the dot takes taps around it ({dot()})</Text>
      </View>

      <View style={{ height: 52 }}>
        <View
          onPress={() => under.update((n) => n + 1)}
          style={{ flex: 1, borderRadius: 12, backgroundColor: "#1a7f37", alignItems: "center", justifyContent: "center" }}
        >
          <Text style={{ color: "#ffffff" }}>Under an overlay: {under()}</Text>
        </View>
        <View
          pointerEvents="none"
          style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, borderRadius: 12, borderWidth: 3, borderColor: "#bf8700", borderStyle: "dashed" }}
        />
      </View>

      <DragCard />
      <ResponderDot />
    </View>
  );
}

mount(App);

Pick the highest level that does the job: the higher the level, the less work crosses from the UI thread to JS.

#Presses

Every element accepts the press handlers; there is no separate “touchable” component. Put onPress on a <View>, a <Text>, an <Image> or anything else.

import { mount, signal, Text, View } from "nsh";

function App() {
  const count = signal(0);
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <View
        onPress={() => count.update((n) => n + 1)}
        onLongPress={() => count.set(0)}
        activeOpacity={0.6}
        accessibilityRole="button"
        style={{ paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10, backgroundColor: "#0969da" }}
      >
        <Text style={{ color: "#ffffff", fontWeight: "600" }}>Pressed {count()} times</Text>
      </View>
    </View>
  );
}

mount(App);
PropTypeDefaultDescription
onPress() => voidThe finger lifted inside the view (after a press, not a long press).
onLongPress() => voidThe finger stayed down for delayLongPress. onPress does not follow.
onPressIn() => voidThe press started.
onPressOut() => voidThe press ended: lifted, slid off, or taken over by a scroll view.
delayLongPressnumber500Milliseconds before a press becomes a long press.
disabledbooleanfalseTakes no presses and shows no feedback; applies disabled: classes.

How presses behave:

  • Only the innermost pressable view responds. A pressable row containing a pressable button gets no press when the button is pressed.
  • Inside a scroll view, the press waits about 130 ms before it starts (and shows feedback), so a drag that begins on a button scrolls instead of pressing it.
  • Sliding off cancels. Moving the finger out of the view ends the press with onPressOut and no onPress.
  • disabled removes the press listeners from what is mounted, so the view shows no feedback at all, and disabled: classes apply (class="disabled:opacity-40").
  • Nested <Text> spans with onPress are links. The paragraph is one native view; the press goes to the span under the finger. See Text.

#Press feedback

Press feedback is drawn by the platform the moment the finger touches down, with no round trip to JS. Choose one per view:

PropTypeDefaultDescription
activeOpacitynumberThe view’s opacity while pressed.
underlayColorColorValueThe background while pressed; the children fade to underlayOpacity.
underlayOpacitynumber0.85The children’s opacity while underlayColor shows.
android={{ ripple }}{ color?, borderless?, radius?, foreground? }Android’s material ripple. Ignored on iOS.
<View onPress={open} activeOpacity={0.5}>…</View>

<View onPress={open} underlayColor="#ddf4ff" style={{ padding: 16 }}>…</View>

<View
  onPress={open}
  activeOpacity={0.6}
  android={{ ripple: { color: "rgba(9,105,218,0.3)" } }}
>
  …
</View>

A borderless ripple spreads outside the view, which suits icon buttons: android={{ ripple: { borderless: true, radius: 24 } }}. Combining activeOpacity with a ripple is common: iOS uses the opacity, Android the ripple. See Platform-specific code.

The active: Tailwind variant is the third option. It applies classes while the view is pressed, and with a transition class the change animates:

<View
  onPress={save}
  class="rounded-xl bg-emerald-600 p-4 transition active:scale-95 active:bg-emerald-800"
>
  <Text class="font-semibold text-white">Save</Text>
</View>

#Hit areas: hitSlop

hitSlop extends the area that takes touches beyond the view’s bounds, without changing its layout. Use it for small targets such as close buttons and toggles.

<View hitSlop={12} onPress={close} style={{ width: 20, height: 20 }}>…</View>
<View hitSlop={{ top: 10, bottom: 10, left: 4, right: 4 }} onPress={next}>…</View>
PropTypeDescription
hitSlopnumber | { top?, right?, bottom?, left? }Points added around the bounds. A number applies to all four sides.

Keep the extended area inside the parent’s bounds (give the small view some margin or padding around it): hit testing reaches a child through its parent, so a touch outside the parent never gets to the child’s slop.

#pointerEvents

pointerEvents decides which views of a subtree can be the target of a touch. It is evaluated natively during hit testing, so it costs nothing per touch.

ValueThe viewIts children
"auto" (default)Takes touchesTake touches
"none"Never the targetNever the target: touches pass through to what is behind
"box-none"Never the targetTake touches
"box-only"Takes touchesNever the target

The typical uses: an overlay that must not block what is under it ("none"), a full-screen container whose empty space should let touches through to the content behind while its buttons still work ("box-none"), and a row that should take a press even when the finger lands on a child ("box-only").

<View style={{ flex: 1 }}>
  <Map />
  {/* A floating toolbar: the empty area passes touches to the map. */}
  <View pointerEvents="box-none" style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0, justifyContent: "flex-end", padding: 16 }}>
    <View onPress={locate} style={{ alignSelf: "flex-end", padding: 12, borderRadius: 24, backgroundColor: "#ffffff" }}>
      <Text>Locate</Text>
    </View>
  </View>
</View>

The Tailwind classes pointer-events-none and pointer-events-auto set the same thing; an explicit prop wins over the class.

#Native gestures: pan, pinch and rotation

For drags, pinches and rotations that move something on screen, bind native values to the gesture. The platform’s gesture recognizer writes the values on the UI thread and the views bound to them move in the same frame; JS is told when the gesture starts and ends.

import { mount, View } from "nsh";
import { createNativeValue } from "nsh/animated";

function App() {
  const x = createNativeValue(0);
  const y = createNativeValue(0);
  return (
    <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
      <View
        pan={{ x, y }}
        onPanEnd={(e) => {
          // Spring back, starting at the speed the finger let go with.
          x.spring(0, { velocity: e.velocityX });
          y.spring(0, { velocity: e.velocityY });
        }}
        style={{ width: 120, height: 120, borderRadius: 24, backgroundColor: "#8250df", transform: [{ translateX: x }, { translateY: y }] }}
      />
    </View>
  );
}

mount(App);
PropTypeDescription
pan{ x?: NativeValue; y?: NativeValue }One-finger drags move these values, in points.
pinch{ scale?: NativeValue }Two-finger pinches drive scale (a factor).
rotation{ angle?: NativeValue }Two-finger rotations drive angle, in degrees.
onPanStart / onPanEnd(e: PanEvent) => voidThe drag started / ended.
onPinchStart / onPinchEnd(e: { scale; velocity }) => voidThe pinch started / ended.
onRotationStart / onRotationEnd(e: { rotation; velocity }) => voidThe rotation started / ended.

PanEvent has translationX / translationY (points since the start), velocityX / velocityY (points per second) and valueX / valueY (the bound values at that moment).

What the native side does for you:

  • A drag continues from where the value is. At the start the values are read, and the translation is added to them; grabbing a value that is still animating (a spring on its way home) stops the animation where it is.
  • One axis only lets the other scroll. With only pan={{ x }} the drag begins only when the finger moves more horizontally than vertically, so a horizontal swipe row inside a vertical scroll view still scrolls vertically. This decision is made natively when the system asks whether the gesture may begin.
  • Pan, pinch and rotation run together on the same view: one card can be dragged, pinched and rotated at once.
  • Release velocity for physics. onPanEnd’s velocity feeds spring({ velocity }) or decay({ velocity }) directly; valueX / valueY let you pick a snap point.
// A card that snaps to the left or right edge.
<View
  pan={{ x }}
  onPanEnd={(e) => {
    const projected = (e.valueX ?? 0) + e.velocityX * 0.2;
    x.spring(projected > 0 ? 140 : -140, { velocity: e.velocityX });
  }}
  style={{ transform: [{ translateX: x }] }}
/>

#Raw touches

The touch handlers report every finger as it goes down, moves and lifts. They are useful for logging, custom hit testing and reacting to multi-touch; they do not claim the touch (presses and scroll views keep working).

PropDescription
onTouchStartA finger went down on the view or inside it.
onTouchMoveA finger moved. Moves are merged per engine turn.
onTouchEndA finger lifted.
onTouchCancelThe system took the touch (a scroll view started scrolling, a call came in).
onTouchStartCapture, onTouchMoveCapture, onTouchEndCapture, onTouchCancelCaptureThe same, called from the outside in, before the bubbling handlers.

Each handler gets a GestureResponderEvent:

FieldDescription
identifierIdentifies the finger while it is down.
locationX, locationYPosition in the view the finger went down on (target).
pageX, pageYPosition in the surface.
targetThe id of the view the finger went down on.
timestampMilliseconds.
touchesEvery finger that is down.
changedTouchesThe fingers this event is about.
nativeEventThe event itself, so e.nativeEvent.locationX works too.
stopPropagation()Views further out do not see this touch event.
<View
  onTouchStart={(e) => console.log("down", e.locationX, e.locationY, e.touches.length)}
  onTouchMove={(e) => console.log("move", e.pageX, e.pageY)}
  onTouchEnd={() => console.log("up")}
/>

#The responder system

When JS has to own a touch sequence (finger drawing, a custom slider, a gesture the native recognizers do not offer), use the gesture responder system. One view at a time is the responder and receives the moves; views negotiate who that is.

Becoming the responder:

PropReturnDescription
onStartShouldSetResponderbooleanAsked when a finger goes down, from the target outwards. Return true to become the responder.
onMoveShouldSetResponderbooleanAsked on every move while there is no responder (or to take it over).
onStartShouldSetResponderCapturebooleanAs above, asked from the outside in, before the bubbling ones. A parent uses it to take a touch from its children.
onMoveShouldSetResponderCapturebooleanThe move version.

While responding:

PropDescription
onResponderGrantThis view is now the responder. Return true to keep native scroll views from taking the touches.
onResponderRejectAnother view is the responder and would not let go.
onResponderStartAnother finger went down.
onResponderMoveA finger moved.
onResponderEndA finger lifted.
onResponderReleaseThe last finger lifted: the gesture is over.
onResponderTerminationRequestAnother view wants the touches. Return false to keep them (default true).
onResponderTerminateAnother view, or a native gesture, took the touches.
import { type GestureResponderEvent, mount, signal } from "nsh";
import { Canvas } from "nsh/canvas";

function Sketch() {
  const strokes = signal<[number, number][][]>([]);
  const point = (e: GestureResponderEvent): [number, number] => [e.locationX, e.locationY];
  return (
    <Canvas
      style={{ flex: 1 }}
      onStartShouldSetResponder={() => true}
      onResponderGrant={(e) => {
        strokes.set([...strokes(), [point(e)]]);
        return true; // the scroll view around must not take the touches
      }}
      onResponderTerminationRequest={() => false}
      onResponderMove={(e) => {
        const all = strokes();
        strokes.set([...all.slice(0, -1), [...all[all.length - 1], point(e)]]);
      }}
      draw={(ctx) => {
        ctx.lineWidth = 3;
        for (const s of strokes()) {
          ctx.beginPath();
          s.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
          ctx.stroke();
        }
      }}
    />
  );
}

mount(Sketch);

How it runs: a view with any touch or responder handler listens to touches natively; the platform reports the touches on it (and inside it) with the path of listening views, and the negotiation runs in the runtime’s Rust code, calling your handlers in order: the …Capture handlers from the outermost view inwards, then the others from the target outwards. Two consequences:

  • Native scroll views and JS responders compete. If onResponderGrant returns true, the scroll views around the responder stop taking the touches until it ends. If a native scroll view starts dragging first, it wins: the responder gets onResponderTerminate and the touch handlers onTouchCancel.
  • Each move is an event to JS. Responder-driven drags are as smooth as your JS is fast; for drags that only move views, prefer pan.

#PanResponder

PanResponder from nsh/gesture wraps the responder system and adds the gesture state: distance from the start, velocity, the number of fingers. Spread its panHandlers on a view.

import { mount, signal, View } from "nsh";
import { PanResponder } from "nsh/gesture";

function Ball() {
  const pos = signal({ x: 0, y: 0 });
  let start = { x: 0, y: 0 };
  const responder = PanResponder.create({
    onStartShouldSetPanResponder: () => true,
    onPanResponderGrant: () => {
      start = pos();
    },
    onPanResponderMove: (_, g) => pos.set({ x: start.x + g.dx, y: start.y + g.dy }),
    onPanResponderRelease: (_, g) => console.log("released at", g.vx, g.vy, "pt/ms"),
  });
  return (
    <View
      {...responder.panHandlers}
      style={{ width: 60, height: 60, borderRadius: 30, backgroundColor: "#0969da", transform: [{ translateX: pos().x }, { translateY: pos().y }] }}
    />
  );
}

mount(Ball);

The callbacks get (event, gestureState):

gestureState fieldDescription
stateIDIdentifies the gesture.
moveX, moveYThe latest position of the touches’ centroid, in surface points.
x0, y0Where the centroid was when the responder was granted.
dx, dyDistance moved since the grant.
vx, vyVelocity, in points per millisecond.
numberActiveTouchesFingers down.

The callbacks are onStartShouldSetPanResponder(Capture), onMoveShouldSetPanResponder(Capture), onPanResponderGrant, onPanResponderReject, onPanResponderStart, onPanResponderMove, onPanResponderEnd, onPanResponderRelease, onPanResponderTerminate, onPanResponderTerminationRequest and onShouldBlockNativeResponder. The last one defaults to true: a granted PanResponder keeps native scroll views from taking its touches.

#Why some answers are props

The main thread never waits for JS. When the system needs an answer on the spot (may this pan gesture begin, does this point hit the view, should this tap dismiss the keyboard), nsh answers natively from props mounted beforehand: pan (its axes), pinch, hitSlop, pointerEvents, keyboardShouldPersistTaps. Your JS handlers hear about what happened afterwards. That is why these are declarative props rather than callbacks, and why the responder system’s negotiation, which does call JS, only decides between JS responders and never blocks a native gesture.

  • View: every touch prop on the basic element.
  • Animation: native values, springs and decay for gesture-driven motion.
  • Scrolling: keyboardShouldPersistTaps, scroll views and nested gestures.
  • nsh/gesture: the PanResponder reference.
  • Accessibility: pressable views are accessibility elements; give them a role and a label.