Library / SDK
usuiat/Zoomable avatar
usuiat/Zoomable

Zoomable adds pinch, double tap and wheel zoom to Compose content with one modifier

Compose multiplatform library that enables the content to be zoomable by gestures

758 stars35 forksKotlinApache-2.0

At a glance

What is it?
Zoomable is a Compose Multiplatform library that makes any composable zoomable through a single Modifier.zoomable extension, with state you can drive yourself, a bouncing boundary, and a support table that is honest about what macOS and desktop cannot do.
Who is it for?
Zoomable fits a Compose Multiplatform codebase that needs a photo or diagram viewer with pinch, double tap and wheel zoom that behaves the same on Android, iOS, web and desktop, and it fits teams that want to own the zoom state rather than accept a fixed viewer widget.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 2 days ago.
What is it written in?
Mainly Kotlin, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One modifier, four gestures

Zoomable is a Compose Multiplatform library that enables content to be zoomable by pinch gesture, double tap, tap and drag gesture, or the mouse scroll wheel. The API surface is one extension function, Modifier.zoomable, and the smallest useful example is three lines:

Kotlin
Image(
    painter = painterResource(id = R.drawable.penguin),
    contentDescription = null,
    modifier = Modifier.zoomable(rememberZoomState()),
)

The claim of the README is that you can make an image zoomable by adding one line of code, and that is accurate: the zoom state is created by rememberZoomState and handed to the modifier.

The three animated demonstrations at the top of the README cover the gestures that matter on different devices. Pinch is the gesture everyone expects on a phone. Double tap is the shortcut people reach for first. Tap and drag is the one that makes a single finger useful for zooming, which matters on a tablet held in one hand or on a large display where a second finger is awkward.

The library composes with what you already have rather than asking for a new widget. The README lists the ways it can be used: combined with any composable such as Image or Text, combined with asynchronous image loading such as coil's rememberAsyncImagePainter, and used inside a HorizontalPager or VerticalPager.

The project is written in Kotlin, licensed under Apache 2.0, and published on Maven Central under the coordinates net.engawapg.lib:zoomable.

The support table is more useful than the feature list

The README publishes a matrix of platforms against the four gestures, and the footnotes are where the honest information sits.

Android, iOS, Wasm and JS have a full row of supported marks. Desktop has a footnote on pinch, because Compose desktop does not support multi-touch gestures, which is a platform limitation rather than a library one. macOS has a dash for pinch, and experimental marks for double tap and tap and drag, because Compose for macOS is experimental, with the project stating that those gestures work but are not officially supported.

The third macOS footnote is the most operationally useful, and it concerns the mouse wheel. Zooming with the scroll wheel only works when MouseWheelZoom.Enabled is set. The default mode, MouseWheelZoom.EnabledWithCtrlKey, does not work on macOS, because Compose for macOS does not report modifier key state.

That is a good example of a documentation footnote saving an afternoon. The default configuration of a wheel-zoom modifier is ctrl plus wheel, which is the familiar browser pattern, and on macOS the modifier cannot be detected, so the feature silently does nothing until you change the setting.

For a library whose whole job is gestures, a table like this tells you more about adopting it than any feature list would, because it tells you exactly which combinations are known to work and which are best effort.

Adding the dependency and the macOS opt-in flag

Zoomable is available on Maven Central, and the Gradle coordinates are given with a version placeholder:

code
repositories {
    mavenCentral()
}

dependencies {
    implementation "net.engawapg.lib:zoomable:$version"
}

The version placeholder is filled from the release badge in the README, and the current tags show v2.16.0 released on 2026-10-05, v2.15.0 on 2026-09-29 and v2.14.0 on 2026-09-20, with the last push to the repository on 2026-10-15.

There is one extra step for native macOS targets. If your app targets macOS natively, you need to add a property to gradle.properties, since Compose for macOS is experimental:

code
org.jetbrains.compose.experimental.macos.enabled=true

That property is the price of the macOS support in the table above. It enables an experimental Compose target, and the library cannot work on native macOS without it, so a build that fails to resolve the macOS source set is the expected symptom of forgetting it.

The repository layout supports that multi-platform shape. There is a zoomable/ module for the library, samples/ with androidApp, desktopApp, iosApp, macosApp, webApp and shared directories, a kotlin-js-store/ directory for the JavaScript dependency lock, a gradle/ directory with the wrapper, and a renovate.json for dependency updates.

The zoom state knows the content size, and that shapes the panning

The zoom state is managed in a ZoomState object created with rememberZoomState, and the state is what the modifier reads and what your callbacks can manipulate.

The important parameter is contentSize. If contentSize is set, the range of offset is optimized for the specified size. That is what stops the content from being dragged into empty space, and it means the picture and the state have to be connected:

Kotlin
val painter = painterResource(id = R.drawable.penguin)
val zoomState = rememberZoomState(contentSize = painter.intrinsicSize)
Image(
    painter = painter,
    contentDescription = "Zoomable image",
    contentScale = ContentScale.Fit,
    modifier = Modifier
        .fillMaxSize()
        .zoomable(zoomState),
)

With an asynchronous image the same pattern applies, because the intrinsic size is only known once the bytes arrive:

Kotlin
val painter = rememberAsyncImagePainter("https://example.com/image.jpg")
val zoomState = rememberZoomState(contentSize = painter.intrinsicSize)

If you use coil's AsyncImage component rather than the painter, the README notes that you need to call setContentSize when the image is successfully loaded, with the onSuccess callback calling zoomState.setContentSize(state.painter.intrinsicSize).

That detail is the practical difference between the two integration styles. With rememberAsyncImagePainter the painter exists immediately, so the intrinsic size is available as a value, while AsyncImage hands you a state object only after the load completes, so the size has to be pushed into the zoom state by hand.

Double tap defaults to 1.0 and 2.5, and the callback is yours to override

By default every time a double tap is detected, the zoomable modifier switches the scale between 1.0f and 2.5f. That default is sensible, and the README shows how to change it, because the modifier accepts an onDoubleTap callback that receives the tap position.

To pick a different target scale, call toggleScale with the value you want:

Kotlin
val targetScale = 5.0f
zoomable(
    zoomState = zoomState,
    onDoubleTap = { position -> zoomState.toggleScale(targetScale, position) }
)

If you want to decide the scale yourself, the changeScale function is the lower level entry point, and the README demonstrates a three step ladder in one callback:

Kotlin
zoomable(
    zoomState = zoomState,
    onDoubleTap = { position ->
        val targetScale = when {
            zoomState.scale < 2f -> 2f
            zoomState.scale < 4f -> 4f
            else -> 1f
        }
        zoomState.changeScale(targetScale, position)
    }
)

The difference between the two functions is the policy. toggleScale switches between the current state and the value you pass, while changeScale sets an absolute target, which is what the three step example needs.

Disabling the behaviour is a matter of passing an empty function to onDoubleTap, which is a smaller change than a boolean flag but works the same way in practice.

Bounce stretches the two boundaries, not the scale itself

While a zoom gesture is in progress the content can be scaled a little beyond its normal range and springs back once the gesture ends, and the Bounce value passed to rememberZoomState configures how far it goes.

The subtlety is in what the two numbers mean. Both values are factors applied to the boundaries of the normal scale range rather than absolute scale values, and the two are applied to different boundaries. The lower factor applies to the minimum scale, which is always 1.0, while the upper factor applies to maxScale.

Kotlin
val zoomState = rememberZoomState(
    maxScale = 5f,
    // Scale can reach 0.8 while shrinking and 6.0 while enlarging.
    bounce = Bounce(lower = 0.8f, upper = 1.2f),
)

The comment in that example is the fastest way to understand it. With a max scale of 5 and an upper factor of 1.2, the content can be pulled to 6.0 while enlarging, and with a lower factor of 0.8 it can be shrunk to 0.8 while shrinking.

The default behaviour is asymmetric. By default the content bounces while shrinking but not while enlarging, which is Bounce.Default, and Bounce.None disables bouncing altogether.

One finger zoom is on by default too, where a tap followed by a vertical drag zooms, and it can be turned off by setting enableOneFingerZoom to false.

Deciding when a scroll gesture belongs to the parent pager

The last piece of API worth understanding is scrollGesturePropagation, which controls when a scroll gesture is handed to the parent Pager composable.

There are two policies. With ContentEdge, the scroll gesture is propagated when the content is scrolled to the edge and attempts to scroll further. With NotZoomed, the gesture is propagated when the content is not zoomed.

That difference matters in a gallery. A HorizontalPager holding zoomable images has to decide whether a horizontal drag pans the image or moves to the next page. With NotZoomed, the pager takes the gesture as soon as the image is back at its base scale, which is the behaviour most photo viewers want: at rest, the pager scrolls. With ContentEdge, the pager only takes over when the image is already at the limit of its panning range, which means a zoomed image consumes the drag until you have panned to the edge, and only then does the pager move.

Neither is wrong, but they feel very different, and the choice has to be made deliberately rather than by default.

The project publishes a rendered API reference at usuiat.github.io/Zoomable/, and the samples directory contains an app for each supported platform, with a shared module for the code they have in common, so you can see the same zoomable content running on Android, iOS, macOS, desktop and web.

Editorial conclusion

Zoomable fits a Compose Multiplatform codebase that needs a photo or diagram viewer with pinch, double tap and wheel zoom that behaves the same on Android, iOS, web and desktop, and it fits teams that want to own the zoom state rather than accept a fixed viewer widget. It is the wrong choice if your target is macOS native and you rely on the mouse wheel with a modifier key, since Compose for macOS does not report modifier key state, and it is the wrong choice for a desktop multi-touch experience, since Compose for desktop does not support multi-touch gestures. Verify first which platform you are shipping, because the support table marks macOS double tap and tap and drag as working but not officially supported, and check that your content size is known, since the offset range is only optimised when contentSize is set.

Frequently asked questions

How do I add Zoomable to a Compose project?

Add the Maven Central dependency net.engawapg.lib:zoomable with a version, then apply Modifier.zoomable(rememberZoomState()) to the composable you want to zoom. For native macOS targets you also need org.jetbrains.compose.experimental.macos.enabled=true in gradle.properties.

Which platforms and gestures does Zoomable support?

Android, iOS, Wasm and JS support pinch, double tap, tap and drag and the mouse scroll wheel. Desktop has no pinch because Compose desktop does not support multi-touch gestures, and on macOS double tap and tap and drag work but are not officially supported, and wheel zoom requires MouseWheelZoom.Enabled.

How do I change the double tap zoom level in Zoomable?

Pass an onDoubleTap callback to the modifier and call zoomState.toggleScale with the scale you want, or use zoomState.changeScale if you want to compute the target yourself. By default the scale switches between 1.0f and 2.5f.

What does the Bounce option do in Zoomable?

Bounce controls how far the content can be scaled beyond its normal range while a gesture is in progress, before springing back. The lower and upper values are factors applied to the boundaries of the normal range, with lower applied to the minimum scale of 1.0 and upper applied to maxScale.

How does Zoomable behave inside a Pager?

The scrollGesturePropagation option decides when a scroll gesture is passed to the parent Pager. With ContentEdge it is propagated when the content is scrolled to the edge and tries to scroll further, and with NotZoomed it is propagated when the content is not zoomed.

Official sources

  1. License: Apache-2.0
  2. Project website
  3. README
  4. Releases
  5. usuiat/Zoomable on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/usuiat-zoomable.svg)](https://hysenlabs.com/projects/usuiat-zoomable)

Was this useful?