Library / SDK
svenjacobs/reveal avatar
svenjacobs/reveal

Reveal: coach marks for Compose Multiplatform without a UI toolkit

Reveal effect (coach mark) for Compose Multiplatform targeting Android, iOS, Desktop and Web

622 stars18 forksKotlinMIT

At a glance

What is it?
Reveal is a Kotlin library that draws coach marks, the onboarding highlights that dim a screen and point at a control, across Android, iOS, Desktop and Web using Compose Multiplatform and nothing else. It suits a Compose team shipping a first-run tutorial, and it is the wrong choice if you need the same effect on a platform Compose does not reach or if you want a tooltip attached to a view rather than a composable.
Who is it for?
Reveal fits a team already building with Compose Multiplatform that wants a first-run walkthrough without pulling in a separate onboarding library, since the API is a composable, a modifier and a state object, and the overlay renders in a full screen popup so it works inside dialogs and bottom sheets.
Can I use it commercially?
Yes. MIT 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 4 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

A coach mark in four pieces: revealable, area, overlay, item

Reveal is a reveal effect, also known as a coach mark, onboarding, tutorial or walkthrough, with a Compose Multiplatform API targeting Android, iOS, Desktop and Web.

The terminology section defines the vocabulary precisely, and it is worth adopting it because the names map onto real implementation concepts. A Revealable is an element which is revealed on the screen. The reveal area is the area which is revealed around the revealable, usually with a slight padding. The overlay is the overlay which greys out all contents except the revealable, and it can contain explanatory items.

So the effect is a scrim that dims the screen, a hole cut in that scrim around the target, and a callout placed next to the hole. The callout is optional, and the library treats the visual highlight and the explanatory text as separate concerns, which is the right split: many onboarding flows want a spotlight with no text, or a tooltip with a custom panel, and a design that couples them forces one compromise on both.

The project was started as an Android library and only became Compose Multiplatform based as of version 3.0. The current release line is 5.x, with 5.2.2 published on 2026-10-17, and the last push to the repository was on 2026-10-19. The licence is MIT, and the topic list covers the whole vocabulary around the effect: coach mark, onboarding, walkthrough, tour, tap target and user onboarding.

The repository layout is a Gradle multi-project with reveal-common/, reveal-core/ and reveal-shapes/ as the library modules, plus a demo-app/ and android-tests/, and it carries AGENTS.md, CLAUDE.md, CONTRIBUTING.md, FAQ.md, MIGRATION.md and a CHANGELOG.md.

The dependency, and the two artifacts you choose between

Installation is one dependency line, and the artifact is on Maven Central:

kotlin
dependencies {
    implementation("com.svenjacobs.reveal:reveal-core:$REVEAL_VERSION")
}

There are two artifacts. reveal-core contains the core classes, and the documentation is unambiguous that you need it. reveal-shapes holds additional shapes for explanatory items, so it is the optional one, for a project that wants more than the default shape vocabulary for its callouts.

The minimum supported Android SDK is 23, which is Android 6.0, and the project is explicit that this is a requirement of Jetpack Compose rather than a choice of its own. That constraint propagates: an application still supporting Android 5 cannot use this library, and no amount of configuration in Reveal changes that.

There is one more compatibility detail that is easy to miss and expensive to debug. The documentation says that when using Reveal on Android you must make sure the version of Google's Jetpack Compose is compatible with the version of Compose Multiplatform that Reveal uses, and points at gradle/libs.versions.toml in the repository for the answer. The mismatch that causes it is easy to imagine, because a project picks a Compose BOM and a Compose Multiplatform version independently, and the library sits on top of both. The fix, when it breaks, is to align the versions the way the library's own version catalog does.

The project also asks for a realistic assessment of platform coverage. As of version 3.0, Reveal is based on Compose Multiplatform targeting Android, iOS, Desktop and Web, but except Android the other platforms are currently not well tested, and some of the targets are still alpha or experimental. That sentence is the single most important line in the readme for an adopter.

How the effect is driven, and the keys that identify the targets

The API has three parts and they map onto three composables and a state object.

The Reveal composable is responsible for registration of, interaction with and rendering the effect for revealable items. There can be many Reveal instances, and there should usually be at most one per screen of an application. The overlay effect is rendered in a full screen popup, which is what lets Reveal be placed anywhere in the Compose hierarchy, including inside a ModalBottomSheet or a Dialog, and the effect is always drawn above the rest of that window's content.

The state comes from rememberRevealState(), and the targets are identified by keys. Inside the Reveal block you mark an element with the revealable modifier, and the key is a value you choose, typically an enum entry:

kotlin
enum class Keys { HelloWorld }

Column {
    Text(
        modifier = Modifier.revealable(key = Keys.HelloWorld),
        text = "Hello world",
    )
}

Then you launch the effect with revealState.reveal(Keys.HelloWorld).

The key is the whole contract. It is what identifies an element for the state, what selects the explanatory item for that element, and what is passed to the click callbacks. A key is not a string here but a typed value, so the compiler catches a typo in the key you reveal, which is a genuine improvement over a tutorial system that identifies its steps by name.

Two error behaviours are defined, and they differ in a way that matters. reveal() throws an IllegalArgumentException naming the keys which are unknown, while tryReveal() returns false in that case. For a tutorial flow driven by user input, tryReveal() is the safer primitive, since a missing key becomes a no-op rather than a crash on a customer's phone.

Explanatory items, and how the overlay knows where to put them

The first reveal effect has no explanation attached, because the callout is a separate concern, supplied through the overlayContent of the Reveal composable.

The signature is a lambda taking the key of the revealed element, so the callout can differ per target:

kotlin
Reveal(
    overlayContent = { key ->
        when (key) {
            Keys.HelloWorld -> {
                Surface(
                    modifier = Modifier
                        .align(horizontalArrangement = RevealOverlayArrangement.Horizontal.Start)
                        .padding(8.dp),
                    shape = RoundedCornerShape(4.dp),
                    color = Color.White,
                ) {
                    Text("This is an explanation")
                }
            }
        }
    }
) {
    // Contents
}

The scope of the overlay content composable provides align() modifiers to align the item either to the start, top, end or bottom of the reveal area, and the documentation is precise about a constraint that will otherwise cost you an afternoon: align() must be applied to a direct child of the overlay content, and it has no effect on elements nested further down the tree.

That restriction is the price of the design. The library knows where the reveal area is, and it hands that knowledge to its immediate children through a scope, but it cannot reach deeper into a subtree to reposition an element it does not directly contain. If your callout is a nested card inside a wrapper, the wrapper is what gets aligned.

Reveal also provides two click listeners. onRevealableClick is called when a reveal area is clicked, with the key of the clicked revealable as the first argument, and onOverlayClick is called when the overlay is clicked somewhere, also with the key argument. The documentation says to use these listeners to reveal the next item, for example for some kind of tutorial, or to hide the effect through revealState.hide().

Revealing several elements at once, and the two state properties

A reveal() call accepts several keys, which reveals all of those elements at the same time, and the overlay content is then called once per revealed element so each of them can have its own explanatory item:

kotlin
enum class Keys { First, Second }

revealState.reveal(Keys.First, Keys.Second)
// or from a collection: revealState.reveal(listOf(Keys.First, Keys.Second))

The state exposes two properties for this case, and they differ in exactly one way. currentRevealableKeys holds the keys of all currently revealed elements, while currentRevealableKey remains the first of them. So a single-element tour and a multi-element tour use the same API, and a UI that shows step three of four can count the array and read the head of it.

The multi-element case has two documented quirks, and both are the kind that only appear once you have users.

The first is which key the overlay click reports. onOverlayClick is called with the key of the first revealed element, since a click outside of all reveal areas does not belong to a specific one, while onRevealableClick always reports the element which was actually clicked. If your tour advances on overlay click, that means clicking outside a multi-highlight step advances the first of them, not the one nearest the click.

The second is OnClick.Passthrough, which is a property of the whole overlay window, so it only takes effect when all revealed elements declare it. That is a sensible rule for a property that changes how touches reach the underlying content, and it means a mixed set of revealables will not behave as you might expect.

The all-or-nothing rule also applies here. Either all elements are revealed or none: reveal() throws an IllegalArgumentException naming the keys which are unknown, and tryReveal() returns false in that case.

Bottom sheets and dialogs, where the window boundary bites

There is a structural constraint in Reveal that comes from how the overlay is rendered, and it is worth understanding before you place a Reveal in a screen with a modal.

Because the overlay is rendered in a popup attached to whichever window Reveal is composed in, revealables inside a ModalBottomSheet or Dialog work as long as Reveal itself, and its revealables, are placed inside that sheet's or dialog's content, with their own RevealState:

kotlin
ModalBottomSheet(onDismissRequest = { /* ... */ }) {
    val revealState = rememberRevealState()

    Reveal(revealState = revealState) {
        // Sheet contents, revealable via Modifier.revealable(...)
    }
}

A Reveal placed outside the sheet cannot reveal items inside it, since they live in different windows. That is the rule stated in the documentation, and it is the correct behaviour rather than a bug: a popup belongs to one window, and a bottom sheet is its own window, so an overlay in the parent window has no way to draw a hole in the child.

The practical consequence is that a tutorial flow involving a bottom sheet needs a Reveal inside that sheet, with its own state, which means the tour sequence has to be split across two states. That is more code than a single Reveal covering the whole screen, and it is a real cost of the popup design that a screen-space overlay would not have.

It also explains the earlier note that the effect is always drawn above the rest of that window's content. The overlay is not a sibling in the Compose tree that happens to be on top; it is a separate window, which is why it can cover a dialog's own content and why the placement rule is about windows rather than about the tree.

Where Reveal is the wrong choice

The first limitation is the one the project states plainly. Except on Android, the other platforms are not well tested, and some of the targets are still alpha or experimental. So a team shipping an iOS or desktop or web onboarding flow is not on the tested path, and will be finding the edges rather than following a documented behaviour.

The second is the scope of the effect. Reveal is a coach mark for Compose composables. It is not a tooltip that attaches to a view, and it is not an annotation system, and it is not a tour framework that sequences steps for you: the sequencing is yours, driven by revealState.reveal() from your own click handlers. That is a fair division of labour for a library, but it does mean the interesting part of a tour, the logic, is still yours to write.

The third is the compatibility coupling with Jetpack Compose on Android. The project asks you to check that the Jetpack Compose version is compatible with the Compose Multiplatform version Reveal uses, and points into its own version catalog to find out. In practice that means your Compose BOM choice is constrained by a third-party library's version catalog, which is a recurring source of upgrade friction in the Compose ecosystem and a reason to check it before committing.

The fourth is the platform floor. Minimum Android SDK 23 excludes Android 5, and it is a Jetpack Compose requirement rather than something the library chose, but the effect is the same for an application with a long tail of old devices.

The fifth is the maturity of the 5.x line. Version 3.0 was the point where the library became Compose Multiplatform based, and the current line is 5.x with 5.2.2 released on 2026-10-17 and the last push on 2026-10-19, which is a fast release cadence for a UI effect library and one that implies a changelog worth reading before upgrading. The repository carries a MIGRATION.md and a FAQ.md alongside the CHANGELOG, and the FAQ page is the place to look for the behaviours the readme does not cover.

Editorial conclusion

Reveal fits a team already building with Compose Multiplatform that wants a first-run walkthrough without pulling in a separate onboarding library, since the API is a composable, a modifier and a state object, and the overlay renders in a full screen popup so it works inside dialogs and bottom sheets. It is the wrong choice if you need the effect outside Compose, and it is the wrong choice if your product targets desktop and web as first-class platforms, because the project states that except Android the other targets are not well tested and some remain alpha or experimental. Before adopting it, check the Compose Multiplatform version Reveal is built against in gradle/libs.versions.toml, confirm your minimum Android SDK is 23 or above, and note that the last push was on 2026-10-19 with version 5.2.2 published on 2026-10-17.

Frequently asked questions

How do I add Reveal to a Compose Multiplatform project?

Add the dependency implementation("com.svenjacobs.reveal:reveal-core:$REVEAL_VERSION"), which is published on Maven Central. The minimum supported Android SDK is 23, and reveal-shapes is an optional artifact for additional shapes for explanatory items.

How do I mark an element as revealable in Reveal?

Use the revealable modifier with a key you define, typically an enum entry, for example Modifier.revealable(key = Keys.HelloWorld), then launch the effect with revealState.reveal(Keys.HelloWorld).

Which platforms does Reveal support?

As of version 3.0 it is based on Compose Multiplatform targeting Android, iOS, Desktop and Web, but the documentation states that except Android the other platforms are currently not well tested and some targets are still alpha or experimental.

What happens if I reveal a key that does not exist?

Either all elements are revealed or none. reveal() throws an IllegalArgumentException naming the unknown keys, while tryReveal() returns false in that case instead of throwing.

Can Reveal highlight items inside a ModalBottomSheet or Dialog?

Yes, as long as the Reveal instance and its revealables are placed inside the sheet's or dialog's content with their own RevealState, because the overlay is rendered as a popup in the window Reveal is composed in. A Reveal outside the sheet cannot reveal items inside it, since they live in different windows.

Official sources

  1. Issues
  2. License: MIT
  3. README
  4. Releases
  5. svenjacobs/reveal 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/svenjacobs-reveal.svg)](https://hysenlabs.com/projects/svenjacobs-reveal)

Was this useful?