In Jetpack Compose, **[`scrollable2D`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/scrollable2D.modifier.md.txt#(androidx.compose.ui.Modifier).scrollable2D(androidx.compose.foundation.gestures.Scrollable2DState,kotlin.Boolean,androidx.compose.foundation.OverscrollEffect,androidx.compose.foundation.gestures.FlingBehavior,androidx.compose.foundation.interaction.MutableInteractionSource))** and **[`draggable2D`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.md.txt#(androidx.compose.ui.Modifier).draggable2D(androidx.compose.foundation.gestures.Draggable2DState,kotlin.Boolean,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Boolean,kotlin.Function1,kotlin.Function1,kotlin.Boolean))** are
low-level modifiers designed to handle pointer input in two dimensions. While
the standard 1D modifiers **[`scrollable`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/scrollable.modifier.md.txt#(androidx.compose.ui.Modifier).scrollable(androidx.compose.foundation.gestures.ScrollableState,androidx.compose.foundation.gestures.Orientation,kotlin.Boolean,kotlin.Boolean,androidx.compose.foundation.gestures.FlingBehavior,androidx.compose.foundation.interaction.MutableInteractionSource))** and **[`draggable`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/draggable.modifier#(androidx.compose.ui.Modifier).draggable(androidx.compose.foundation.gestures.DraggableState,androidx.compose.foundation.gestures.Orientation,kotlin.Boolean,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Boolean,kotlin.coroutines.SuspendFunction2,kotlin.coroutines.SuspendFunction2,kotlin.Boolean)))** are
restricted to a single orientation, the 2D variants track movement across both
the X and Y axes simultaneously.

For example, the existing `scrollable` modifier is used for single-orientation
scrolling and flinging, while `scrollable2d` is used for scrolling and flinging
in 2D. This allows you to create more complex layouts that move in all
directions, such as spreadsheets or image viewers. The `scrollable2d` modifier
also supports nested scrolling in 2D scenarios.
**Figure 1.** A bi-directional panning on a map.

## Choose `scrollable2D` or `draggable2D`

Choosing the right API depends on the UI elements you want to move and the
preferred physical behavior for these elements.

**[`Modifier.scrollable2D`](https://developer.android.com/reference/kotlin/androidx/compose/foundation/gestures/scrollable2D.modifier.md.txt#(androidx.compose.ui.Modifier).scrollable2D(androidx.compose.foundation.gestures.Scrollable2DState,kotlin.Boolean,androidx.compose.foundation.OverscrollEffect,androidx.compose.foundation.gestures.FlingBehavior,androidx.compose.foundation.interaction.MutableInteractionSource))**: Use this modifier on a container to move content inside it. For example, use it with maps, spreadsheets, or photo viewers, where the container's content needs to scroll in both horizontal and vertical directions. It includes built-in fling support so the content keeps moving after a swipe, and it coordinates with other scrolling components on the page.

**[`Modifier.draggable2D`](https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier.md.txt#(androidx.compose.ui.Modifier).draggable2D(androidx.compose.foundation.gestures.Draggable2DState,kotlin.Boolean,androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Boolean,kotlin.Function1,kotlin.Function1,kotlin.Boolean))**: Use this modifier to move a component itself. It's a lightweight modifier, so the movement stops exactly when the user's finger stops. It does not include fling support.

If you want to make a component draggable, but don't need fling or nested scroll
support, use `draggable2D`.

## Implement 2D modifiers

The following sections provide examples to show how to use the 2D modifiers.

### Implement `Modifier.scrollable2D`

Use this modifier for containers where the user needs to move content in all
directions.

#### Capture 2D movement data

This example shows how to capture raw 2D movement data and display the X,Y
offset:


```kotlin
@Composable
private fun Scrollable2DSample() {
    // 1. Manually track the total distance the user has moved in both X and Y directions
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            // ...
        contentAlignment = Alignment.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                // 2. Attach the 2D scroll logic to capture XY movement deltas
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        // 3. Update the cumulative offset state with the new movement delta
                        offset += delta

                        // Return the delta to indicate the entire movement was handled by this box
                        delta
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                // 4. Display the current X and Y values from the offset state in real-time
                Text(
                    text = "X: ${offset.x.roundToInt()}",
                    // ...
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "Y: ${offset.y.roundToInt()}",
                    // ...
                )
            }
        }
    }
}
```

<br />

**Figure 2.**A purple box that tracks and displays the current X and Y coordinate offsets as a user drags the pointer across its surface.

The preceding snippet does the following:

- Uses `offset` as a state that holds the total distance the user has scrolled.
- Inside `rememberScrollable2DState`, a lambda function is defined to handle every delta, generated by the user's finger. The code `offset.value += delta` updates the manual state with the new position.
- The `Text` components display the current X and Y values of that `offset` state, which update in real-time as the user drags.

#### Pan a large viewport

This example shows how to use captured 2D scrollable data and apply a
`translationX` and `translationY` to content that is larger than its parent
container:


```kotlin
@Composable
private fun Panning2DImage() {

    // Manually track the total distance the user has moved in both X and Y directions
    val offset = remember { mutableStateOf(Offset.Zero) }

    // Define how gestures are captured. The lambda is called for every finger movement
    val scrollState = rememberScrollable2DState { delta ->
        offset.value += delta
        delta
    }

    // The Viewport (Container): A fixed-size box that acts as a window into the larger content
    Box(
        modifier = Modifier
            .size(600.dp, 400.dp) // The visible area dimensions
            // ...
            // Hide any parts of the large content that sit outside this container's boundaries
            .clipToBounds()
            // Apply the 2D scroll modifier to intercept touch and fling gestures in all directions
            .scrollable2D(state = scrollState),
        contentAlignment = Alignment.Center,
    ) {
        // The Content: An image given a much larger size than the container viewport
        Image(
            painter = painterResource(R.drawable.cheese_5),
            contentDescription = null,
            modifier = Modifier
                .requiredSize(1200.dp, 800.dp)
                // Manual Scroll Effect: Since scrollable2D doesn't move content automatically,
                // we use graphicsLayer to shift the drawing position based on the tracked offset.
                .graphicsLayer {
                    translationX = offset.value.x
                    translationY = offset.value.y
                },
            contentScale = ContentScale.FillBounds
        )
    }
}
```

<br />

**Figure 3.** A bi-directional panning image viewport, created with `Modifier.scrollable2D`. **Figure 4.** A bi-directional panning text viewport, created with `Modifier.scrollable2D`.

The preceding snippet includes the following:

- The container is set to a fixed size (`600x400dp`), while the content is given a much larger size (`1200x800dp`) to avoid it resizing to its parent size.
- The `clipToBounds()` modifier on the container ensures that any part of the large content that sits outside the `600x400` box is hidden from view.
- Unlike high-level components like `LazyColumn`, `scrollable2D` does not move the content for you automatically. Instead, you must apply the tracked `offset` to your content, either using `graphicsLayer` transformations or layout offsets.
- Inside the `graphicsLayer` block, `translationX = offset.value.x` and `translationY = offset.value.y` shift the drawing position of the image or text based on your finger's movement, creating the visual effect of scrolling.

#### Implement nested scrolling with scrollable2D

This example demonstrates how a bi-directional component can be integrated into
a standard one-dimensional parent, like a vertical news feed.

Keep the following points in mind when implementing nested scrolling:

- The lambda for `rememberScrollable2DState` should return only the **consumed
  delta** , to let the **parent list take over naturally** when the child reaches its limit.
- When a user performs a diagonal fling, the 2D velocity is shared. If the child hits a boundary during the animation, the **remaining momentum is propagated** to the parent to continue the scroll naturally.


```kotlin
@Composable
private fun NestedScrollable2DSample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val maxScrollDp = 250.dp
    val maxScrollPx = with(LocalDensity.current) { maxScrollDp.toPx() }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .background(Color(0xFFF5F5F5)),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            "Scroll down to find the 2D Box",
            modifier = Modifier.padding(top = 100.dp, bottom = 500.dp),
            style = TextStyle(fontSize = 18.sp, color = Color.Gray)
        )

        // The Child: A 2D scrollable box with nested scroll coordination
        Box(
            modifier = Modifier
                .size(250.dp)
                .scrollable2D(
                    state = rememberScrollable2DState { delta ->
                        val oldOffset = offset

                        // Calculate new potential offset and clamp it to our boundaries
                        val newX = (oldOffset.x + delta.x).coerceIn(-maxScrollPx, maxScrollPx)
                        val newY = (oldOffset.y + delta.y).coerceIn(-maxScrollPx, maxScrollPx)

                        val newOffset = Offset(newX, newY)

                        // Calculate exactly how much was consumed by the child
                        val consumed = newOffset - oldOffset

                        offset = newOffset

                        // IMPORTANT: Return ONLY the consumed delta.
                        // The remaining (unconsumed) delta propagates to the parent Column.
                        consumed
                    }
                )
                // ...
            contentAlignment = Alignment.Center
        ) {
            Column(horizontalAlignment = Alignment.CenterHorizontally) {
                val density = LocalDensity.current
                Text("2D Panning Zone", color = Color.White.copy(alpha = 0.7f), fontSize = 12.sp)
                Spacer(Modifier.height(8.dp))
                Text("X: ${with(density) { offset.x.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
                Text("Y: ${with(density) { offset.y.toDp().value.roundToInt() }}dp", color = Color.White, fontWeight = FontWeight.Bold)
            }
        }

        Text(
            "Once the Purple Box hits Y: 250 or -250,\nthis parent list will take over the vertical scroll.",
            textAlign = TextAlign.Center,
            modifier = Modifier.padding(top = 40.dp, bottom = 800.dp),
            style = TextStyle(fontSize = 14.sp, color = Color.Gray)
        )
    }
}
```

<br />

**Figure 5.**A purple box within a vertical scrolling list that allows internal 2D movement, but passes vertical scroll control to the parent list once the box's internal Y-offset reaches its 300-pixel limit.

In the preceding snippet:

- The 2D component can consume X axis movement to pan internally while simultaneously dispatching Y axis movement to the parent list once the child's own vertical boundaries are reached.
- Instead of trapping the user within the 2D surface, the system calculates the consumed delta and passes the remainder up the hierarchy. This ensures the user can continue scrolling through the rest of the page without lifting their finger.

### Implement `Modifier.draggable2D`

For information on how to use `Modifier.draggable2D`, see [Drag in two
dimensions with `Modifier.draggable2D`](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/drag-composables#modifier-draggable2d).