> ## Documentation Index
> Fetch the complete documentation index at: https://docs.metabind.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Gestures

> Modifiers that detect taps, drags, long presses, and pointer hover

export const PlatformStatuses = ({statuses}) => {
  const StatusBadge = ({status, label}) => {
    const styles = {
      green: {
        backgroundColor: '#dcfce7',
        color: '#166534'
      },
      orange: {
        backgroundColor: '#fed7aa',
        color: '#9a3412'
      },
      red: {
        backgroundColor: '#fecaca',
        color: '#991b1b'
      },
      gray: {
        backgroundColor: '#f3f4f6',
        color: '#4b5563'
      }
    };
    const baseStyle = {
      display: 'inline-flex',
      alignItems: 'center',
      padding: '0.125rem 0.625rem',
      borderRadius: '9999px',
      fontSize: '0.875rem',
      fontWeight: '500'
    };
    const colorStyle = styles[status] || styles.green;
    return <span style={{
      ...baseStyle,
      ...colorStyle
    }}>
        {label || status}
      </span>;
  };
  const STATUS_CONFIG = {
    supported: {
      label: "Supported",
      color: "green"
    },
    partial: {
      label: "Partial",
      color: "orange"
    },
    "not-implemented": {
      label: "Not Implemented",
      color: "gray"
    }
  };
  const renderCard = (platform, value) => {
    if (!value) return null;
    const {status, note} = typeof value === "string" ? {
      status: value
    } : value;
    const config = STATUS_CONFIG[status];
    if (!config) return null;
    const titleMap = {
      ios: "SwiftUI",
      android: "Jetpack Compose",
      web: "Web"
    };
    return <Card key={platform} title={titleMap[platform] || platform}>
          <StatusBadge status={config.color} label={config.label} />
          {note && <div style={{
      marginTop: '0.5rem',
      fontSize: '0.875rem',
      color: '#6b7280'
    }}>
              {note}
            </div>}
      </Card>;
  };
  if (statuses == null) {
    return null;
  }
  return <Columns cols="3">
      {Object.entries(statuses).map(([platform, value]) => renderCard(platform, value))}
    </Columns>;
};

export const ComposeJS = ({code, name, height}) => {
  const encodedCode = useMemo(() => {
    if (!code) return "";
    try {
      return btoa(code);
    } catch (e) {
      console.error("Failed to encode code", e);
      return "";
    }
  }, [code]);
  if (!encodedCode) {
    return null;
  }
  return <iframe src={`https://www.metabind.ai/embed?code=${encodedCode}&name=${name ?? 'Example'}`} loading="lazy" style={{
    width: "100%",
    height: height || '350px',
    border: "1px solid #e5e7eb",
    borderRadius: "var(--rounded-2xl,1rem)",
    overflow: "hidden"
  }} title="ComposeJS Preview" />;
};

Gesture modifiers attach interaction handlers to any component. `onTapGesture` runs on a single (or multi-) tap; `onDragGesture` reports drag translation and velocity through every phase; `onLongPressGesture` fires after a press-and-hold; `onHover` tracks pointer enter/leave on web and desktop.

For interactive controls, prefer `Button` directly — gesture modifiers are best for adding handling to otherwise non-interactive components like `Text`, `Image`, and shapes.

## onTapGesture

Runs an action when the component is tapped.

```typescript theme={null}
.onTapGesture(action: (locationInView: Point) => void): Component
.onTapGesture(props: { count: number }, action: (locationInView: Point) => void): Component
```

<ParamField path="action" type="(locationInView: Point) => void" required>
  A callback that receives the tap location as a `Point` (`{ x, y }`) in the component's coordinate space.
</ParamField>

<ParamField path="props" type="object" optional>
  Configuration options for the tap gesture.

  <Expandable title="properties">
    <ParamField path="count" type="number" optional>
      The number of taps required to trigger the action (e.g., `2` for double-tap).
    </ParamField>
  </Expandable>
</ParamField>

<PlatformStatuses
  statuses={{
ios: { status: "supported" },
android: { status: "supported" },
web: { status: "supported" },
}}
/>

**Single tap**

```typescript theme={null}
Text("Tap me")
    .onTapGesture((location) => {
        console.log("Tapped at: " + location.x + ", " + location.y)
    })
```

**Simple tap action (ignoring location)**

```typescript theme={null}
const body = () => {
    const [count, setCount] = useState(0)

    return Text("Tapped " + count + " times")
        .padding(16)
        .background(Color("blue").opacity(0.1))
        .cornerRadius(8)
        .onTapGesture(() => setCount(count + 1))
}
```

**Double tap**

```typescript theme={null}
const body = () => {
    const [zoomed, setZoomed] = useState(false)

    return Image({ url: "photo.jpg" })
        .resizable()
        .scaledToFit()
        .scaleEffect(zoomed ? 2 : 1)
        .onTapGesture({ count: 2 }, () => setZoomed(!zoomed))
}
```

**Combining single and double tap**

```typescript theme={null}
const body = () => {
    const [label, setLabel] = useState("Tap or double tap")

    return Text(label)
        .padding(16)
        .onTapGesture({ count: 2 }, () => setLabel("Double tapped!"))
        .onTapGesture(() => setLabel("Single tapped!"))
}
```

The `locationInView` is in the tapped component's local coordinate space, with `{ x: 0, y: 0 }` at the top-leading corner. When combining single and multi-tap gestures, apply the higher count gesture first — the system waits briefly to distinguish between single and multi-tap. For interactive controls like buttons, prefer using `Button` directly.

## onDragGesture

Tracks drag gestures with translation and velocity.

```typescript theme={null}
.onDragGesture(action: (state: DragGestureState) => void): Component
.onDragGesture(props: { minimumDistance?: number }, action: (state: DragGestureState) => void): Component
```

<ParamField path="action" type="(state: DragGestureState) => void" required>
  A callback that receives the drag gesture state throughout the gesture lifecycle.

  <Expandable title="DragGestureState">
    <ParamField path="phase" type="&#x22;possible&#x22; | &#x22;began&#x22; | &#x22;changed&#x22; | &#x22;ended&#x22; | &#x22;cancelled&#x22;">
      The current phase of the gesture.
    </ParamField>

    <ParamField path="locationInView" type="Point">
      Touch/pointer location in the component's coordinate space (`{ x, y }`).
    </ParamField>

    <ParamField path="translation" type="Point">
      Cumulative translation from the drag start point (`{ x, y }`).
    </ParamField>

    <ParamField path="velocity" type="Point">
      Current drag velocity in points per second (`{ x, y }`).
    </ParamField>
  </Expandable>
</ParamField>

<ParamField path="props" type="object" optional>
  Configuration options for the drag gesture.

  <Expandable title="properties">
    <ParamField path="minimumDistance" type="number" optional>
      The minimum drag distance in points before the gesture is recognized.
    </ParamField>
  </Expandable>
</ParamField>

<PlatformStatuses
  statuses={{
ios: { status: "supported" },
android: { status: "supported" },
web: { status: "supported" },
}}
/>

**Basic drag tracking**

```typescript theme={null}
const body = () => {
    const [offset, setOffset] = useState({ x: 0, y: 0 })

    return Circle()
        .frame({ width: 60, height: 60 })
        .foregroundStyle(Color("blue"))
        .offset(offset)
        .onDragGesture((state) => {
            if (state.phase === "changed") {
                setOffset({
                    x: state.translation.x,
                    y: state.translation.y
                })
            }
            if (state.phase === "ended") {
                setOffset({ x: 0, y: 0 })
            }
        })
}
```

**With minimum distance**

Require a longer drag before recognition to avoid interfering with taps.

```typescript theme={null}
const body = () => {
    const [dragging, setDragging] = useState(false)

    return Rectangle()
        .frame({ width: 100, height: 100 })
        .foregroundStyle(dragging ? Color("red") : Color("blue"))
        .onDragGesture({ minimumDistance: 20 }, (state) => {
            setDragging(
                state.phase === "began" ||
                state.phase === "changed"
            )
        })
}
```

**Swipe detection using velocity**

```typescript theme={null}
const body = () => {
    const [direction, setDirection] = useState("")

    return Text(direction || "Swipe me")
        .frame({ width: 200, height: 200 })
        .background(Color("gray").opacity(0.2))
        .onDragGesture((state) => {
            if (state.phase === "ended") {
                if (state.velocity.x > 500) {
                    setDirection("Swiped right")
                } else if (state.velocity.x < -500) {
                    setDirection("Swiped left")
                }
            }
        })
}
```

The gesture callback fires for every phase: `"possible"`, `"began"`, `"changed"`, `"ended"`, and `"cancelled"`. `translation` is cumulative from the initial touch point, not a per-frame delta. `velocity` is in points per second and is useful for flick/swipe detection.

## onLongPressGesture

Tracks long press gestures.

```typescript theme={null}
.onLongPressGesture(action: (state: GestureState) => void): Component
.onLongPressGesture(props: { minimumDuration?: number; maximumDistance?: number }, action: (state: GestureState) => void): Component
```

<ParamField path="action" type="(state: GestureState) => void" required>
  A callback that receives the gesture state throughout the gesture lifecycle.

  <Expandable title="GestureState">
    <ParamField path="phase" type="&#x22;possible&#x22; | &#x22;began&#x22; | &#x22;changed&#x22; | &#x22;ended&#x22; | &#x22;cancelled&#x22;">
      The current phase of the gesture.
    </ParamField>

    <ParamField path="locationInView" type="Point">
      Touch/pointer location in the component's coordinate space (`{ x, y }`).
    </ParamField>
  </Expandable>
</ParamField>

<ParamField path="props" type="object" optional>
  Configuration options for the long press gesture.

  <Expandable title="properties">
    <ParamField path="minimumDuration" type="number" optional>
      The minimum press duration in seconds before the gesture is recognized.
    </ParamField>

    <ParamField path="maximumDistance" type="number" optional>
      The maximum distance in points the finger can move before the gesture is cancelled.
    </ParamField>
  </Expandable>
</ParamField>

<PlatformStatuses
  statuses={{
ios: { status: "supported" },
android: { status: "supported" },
web: { status: "supported" },
}}
/>

**Basic long press**

```typescript theme={null}
const body = () => {
    const [pressed, setPressed] = useState(false)

    return Text(pressed ? "Long pressed!" : "Press and hold")
        .padding(16)
        .background(Color("gray").opacity(0.2))
        .cornerRadius(8)
        .onLongPressGesture((state) => {
            if (state.phase === "ended") {
                setPressed(true)
            }
        })
}
```

**Custom duration**

Require a 2-second press.

```typescript theme={null}
Text("Hold for 2 seconds")
    .padding(16)
    .onLongPressGesture({ minimumDuration: 2 }, (state) => {
        if (state.phase === "ended") {
            console.log("Long press recognized")
        }
    })
```

**Visual feedback during press**

```typescript theme={null}
const body = () => {
    const [pressing, setPressing] = useState(false)

    return Circle()
        .frame({ width: 80, height: 80 })
        .foregroundStyle(pressing ? Color("red") : Color("blue"))
        .scaleEffect(pressing ? 0.9 : 1)
        .onLongPressGesture((state) => {
            setPressing(
                state.phase === "began" ||
                state.phase === "changed"
            )
        })
}
```

The gesture callback fires for every phase: `"possible"`, `"began"`, `"changed"`, `"ended"`, and `"cancelled"`. Use `"began"` and `"changed"` to show press-in-progress feedback, and `"ended"` to perform the action. If the finger moves beyond `maximumDistance`, the gesture is cancelled.

## onHover

Fires when the pointer hovers over or leaves the component.

```typescript theme={null}
.onHover(action: (isHovering: boolean) => void): Component
```

<ParamField path="action" type="(isHovering: boolean) => void" required>
  A callback that receives `true` when the pointer enters the component and `false` when it leaves.
</ParamField>

<PlatformStatuses
  statuses={{
ios: "not-implemented",
android: "not-implemented",
web: { status: "supported" },
}}
/>

**Hover highlight**

```typescript theme={null}
const body = () => {
    const [hovered, setHovered] = useState(false)

    return Text("Hover me")
        .padding(16)
        .background(hovered ? Color("blue").opacity(0.1) : Color("clear"))
        .cornerRadius(8)
        .onHover((isHovering) => setHovered(isHovering))
}
```

**Changing text on hover**

```typescript theme={null}
const body = () => {
    const [hovered, setHovered] = useState(false)

    return Text(hovered ? "Hovered!" : "Hover me")
        .foregroundStyle(hovered ? Color("blue") : Color("gray"))
        .onHover((isHovering) => setHovered(isHovering))
}
```

**Scale effect on hover**

```typescript theme={null}
const body = () => {
    const [hovered, setHovered] = useState(false)

    return Image({ url: "thumbnail.jpg" })
        .resizable()
        .frame({ width: 100, height: 100 })
        .scaleEffect(hovered ? 1.05 : 1)
        .onHover((isHovering) => setHovered(isHovering))
}
```

This modifier is primarily useful for web and desktop pointer interactions. On touch-only devices, hover events do not occur. Currently only supported on the web platform.

## See also

* [Button](/bindjs/components/Button) — interactive control with built-in tap handling
* [contextMenu](/bindjs/modifiers/contextMenu) — show a menu on long press or right-click
* [allowsHitTesting](/bindjs/modifiers/allowsHitTesting) — control whether a component receives events
* [contentShape](/bindjs/modifiers/contentShape) — define the hit-testing region
