Overlays for a Compose Multiplatform app (android, iosArm64, iosSimulatorArm64) built on foundation alone — no Material: dialogs, bottom sheets, dropdown menus, tooltips, snackbars and showcases, all drawn by one host at the root of the app, in the app's own composition rather than in separate windows.
@Composable
fun AppRoot(graph: AppGraph) {
// The renderers, contributed by OverlayWiring to the graph's Set<ProvidedValue<*>>.
CompositionLocalProvider(*graph.compositionLocals.toTypedArray()) {
OverlayTheme(styles = appOverlayStyles()) { // the app's tokens, mapped once
OverlayHost { // every overlay of the kit, stacked right
AppContent()
}
}
}
}
@Composable // in a feature module: overlay/api only
fun DeleteButton(onDelete: () -> Unit) {
var isAsking by remember { mutableStateOf(false) }
Button(onClick = { isAsking = true }) { Text("Delete") }
OverlayDialog(isVisible = isAsking, onDismissRequest = { isAsking = false }) {
Text("Delete this post?")
TextButton(onClick = { isAsking = false; onDelete() }) { Text("Delete") }
}
}Because the overlays are in the app's composition, the content behind a sheet can be pushed back as it rises, one stack orders every overlay, and Android and iOS behave alike. What a separate window gives for free the host does itself: the content behind a modal is hidden from screen readers and from keyboard focus, focus moves into the modal and comes back after, and back — the button, Android's predictive gesture, iOS's edge swipe — closes the top overlay.
| Read | For |
|---|---|
| this file | what is here and how it is built |
overlay/api/README.md |
using the overlays, task by task: the root, each overlay, styling, previews, tests |
overlay/impl/README.md |
the host underneath: writing an overlay plugin of your own, anchored placement, the modal lifecycle |
overlay/README.md |
why each part has its shape, and what changed from the app it came from |
skills/overlay-kit |
the same for an agent working in an app that uses the kit |
An application takes the kit by copy, not as a dependency: the code is copied into the app, renamed to the app's own package, and belongs to the app from then on. Nothing is published to a Maven repository.
If you have access to the author's knowledge repository (github.com/Thernal/knowledge), its skill-manager skill does all of it — copy, rename, the skill, and later updates:
skillctl.sh kit install overlay-kit --package com.example.app --module :core:overlay \
--alias appIt copies the code parts of kit.yml renamed, installs the overlay-kit skill and records the copy in kits.lock. kit status then shows what changed upstream and what the app edited; kit update merges the kit's changes three ways, keeping the app's edits. The install prints what the app must provide (requires).
The same by hand, from a clone of this repository.
-
Copy the paths listed under
codeinkit.ymlinto the app, under the module path the app gives them:overlay/…→core/overlay/…. Note the commit you copied (git rev-parse HEAD) — updates start from it. -
Rename in everything copied:
In the kit Becomes Where io.thernal.overlaykitthe app's package, e.g. com.example.appsources, build files; and the directories io/thernal/overlaykit:overlay:and":overlay",projects.overlay.the module path, e.g. :core:overlay:,projects.core.overlay.build files libs.plugins.overlaykit.the app's catalog alias, e.g. libs.plugins.app.build files # in the app, after copying — perl, so it runs the same on macOS and Linux grep -rlI -e io.thernal.overlaykit -e io/thernal/overlaykit -e :overlay -e plugins.overlaykit. core/overlay \ | xargs perl -pi -e 's/\Qio.thernal.overlaykit\E/com.example.app/g; s{\Qio/thernal/overlaykit\E}{com/example/app}g; s/\Q:overlay:\E/:core:overlay:/g; s/"\Q:overlay\E"/":core:overlay"/g; s/projects\.\Qoverlay\E\./projects.core.overlay./g; s/libs\.plugins\.\Qoverlaykit\E\./libs.plugins.app./g' find core/overlay -depth -type d -path '*/io/thernal/overlaykit' | while read -r d; do mkdir -p "${d%/io/thernal/overlaykit}/com/example" && mv "$d" "${d%/io/thernal/overlaykit}/com/example/app" done find core/overlay -depth -type d -empty -delete
-
Provide what the copy expects — the
requireslist inkit.yml: convention plugins (build-kit's, or the ones in this repository'sbuild-logic/convention), catalog entries, settings. -
The skill (optional): copy
skills/overlay-kitinto the app's skills directory (.claude/skills/for Claude Code), with the same renames, so an agent working in the app knows the kit. -
Updates are yours to carry:
git diff <the commit you copied> <a newer one> -- <the code paths>in the kit shows what changed; apply what you want, renamed the same way.
| Module | Holds | Depends on |
|---|---|---|
overlay/api |
the contract: OverlayHost, OverlayDialog, OverlayBottomSheet, OverlayDropdown, OverlayTooltip, OverlayShowcase, each over a …Renderer in a CompositionLocal that previews it; the styles, OverlayTheme, SnackbarMessage, LocalSnackbarManager, OverlayPlacement, OverlayLayerPlugin |
Compose |
overlay/impl |
the renderers; the plugin host, backdrop, anchors, anchored placement (AnchoredOverlay), the modal lifecycle, the scrim, back handling; each overlay's plugin and host; the snackbar queue |
api, Compose, navigationevent |
overlay/wiring |
the renderers as ProvidedValues in the app graph |
api, impl, Metro |
overlay/testing |
RecordingSnackbarManager, InlineOverlayRenderers (overlays drawn in place, for UI tests), ImmediateFrameClock |
api |
sample/shared, sample/android, sample/ios |
one screen with every overlay, and a switch to an app's mapped styles | the kit — never copied |
./gradlew buildEvery target, tests on the JVM host and the iOS simulator — placement geometry and the resolver, the modal
lifecycle, the sheet's settle rule, the snackbar queue (timers, holding, stale timers), the testing doubles
— Detekt, which fails on any finding (-PdetektAutoCorrect=true fixes formatting first), Android lint on
the sample, and the sample's iOS framework link. Building for iOS needs Xcode, not only its command-line
tools.
The iOS sample is an Xcode project (sample/ios/Sample.xcodeproj, generated from project.yml by
XcodeGen); its build phase compiles the framework with Gradle:
xcodebuild -project sample/ios/Sample.xcodeproj -scheme Sample -sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' build