A Jetpack Compose dialog design system, built on the three-layer UI architecture (Component β Variant β Primitive β Tokens).
Version 2.x was a rewrite. Where 1.x offered eight decorated AlertDialogs, it
offers seven component families β six dialogs and a set of banners β that share
one frame, one theme and one set of accessibility guarantees β plus the visual identity the library has always had.
3.0 changes two things. Every component exposes one callback per interaction β
onConfirm, onCancel, onDismiss β instead of a single onSignal taking a
sealed type. And banners gained the seven behaviours their model could not
express: a trailing action, a docked status strip, swipe-to-dismiss, a queue
policy, background progress, a presence dot and a visible countdown. Nothing that
already existed changed how it looks.
Coming from 1.x or from 2.0? Start with the migration guide.
- Why it exists
- Install
- Quick start
- The dialogs
- Notifications
- Theming
- Architecture
- Accessibility
- Catalog
- Contributing
- License
Every Android app writes the same dialogs, and writes them slightly differently each time: one forgets the loading state, another lets you cancel a half-finished delete, a third is 300dp wide on a tablet, a fourth announces nothing to TalkBack.
AestheticDialogs does that work once. What it gives you is not "a nicer
AlertDialog" β it is a set of dialogs that are already correct about the
things that are easy to get wrong:
- adaptive β the width comes from the space available, not from a device check;
- accessible β pane titles, headings, live regions, 48dp targets, 200% font;
- stateless β the library never decides that your dialog should close;
- themed β one wrapper, light and dark, brandable by copying a value;
- quiet under reduced motion β transitions become cuts when the user asks;
- light β no icon library, no drawable resources, and Material 3 never in a public signature, so you never have to write Material to use the library.
// settings.gradle.kts β Maven Central is already there in most projects
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}// build.gradle.kts
dependencies {
implementation("com.gabrielthecode:aestheticdialogs:3.0.0")
}Or through a version catalog:
# gradle/libs.versions.toml
[versions]
aestheticdialogs = "3.0.0"
[libraries]
aestheticdialogs = { module = "com.gabrielthecode:aestheticdialogs", version.ref = "aestheticdialogs" }dependencies {
implementation(libs.aestheticdialogs)
}Requires minSdk 24 and a Compose-enabled module. Material 3 is not forced on
you β the library keeps it as an implementation detail.
Wrap your app once, inside your own theme:
setContent {
MyAppTheme { // yours: untouched
AestheticDialogsTheme { // ours: four CompositionLocals, nothing else
AppNavHost()
}
}
}Optional, in fact β components resolve light or dark from the system setting when no theme is present. Wrapping is what lets you brand them.
Then a dialog is a function of your state:
if (uiState.showDeleteConfirmation) {
AestheticConfirmationDialog(
uiModel = ConfirmationDialogUiModel.Destructive(
title = "Delete this album?",
message = "The 24 photos inside it will be deleted too.",
confirmLabel = "Delete album",
cancelLabel = "Keep it",
isConfirming = uiState.isDeleting,
),
onConfirm = { viewModel.deleteAlbum() },
onCancel = { viewModel.dismissDialog() },
onDismiss = { viewModel.dismissDialog() },
)
}There is no show() and no dismiss(). The dialog is on screen while it is in
the composition, and every callback β including onDismiss β is a request you
decide what to do with.
Every image below is rendered from the real component by
scripts/generate-docs-images.sh, so it cannot drift from the library.
Two variants: Default for ordinary questions, Destructive for things that
cannot be undone. The destructive treatment is guaranteed rather than
configured, so a delete confirmation cannot accidentally ship with a neutral
button.
AestheticConfirmationDialog(
uiModel = ConfirmationDialogUiModel.Default(
title = "Leave without saving?",
message = "Your draft has unsaved changes.",
confirmLabel = "Leave",
cancelLabel = "Keep editing",
),
onConfirm = { viewModel.discardDraft() },
onCancel = { viewModel.dismissDialog() },
onDismiss = { viewModel.dismissDialog() },
)isConfirming = true puts a spinner on the confirm button and locks the rest of
the dialog β including cancel, so a half-finished operation cannot be abandoned
mid-flight.
| Default | Destructive | Confirming |
|---|---|---|
![]() |
![]() |
![]() |
AestheticAlertDialog(
uiModel = AlertDialogUiModel.Default(
title = "You are offline",
message = "We will sync your changes as soon as you reconnect.",
tone = DialogTone.Warning,
primaryAction = DialogAction("Retry"),
secondaryAction = DialogAction("Dismiss", DialogActionEmphasis.Text),
),
onPrimaryAction = { viewModel.retry() },
onDismiss = { viewModel.dismissDialog() },
onSecondaryAction = { viewModel.dismissDialog() },
)This is also the error, offline and permission-required pattern. They are the same dialog with a different tone and action label, and shipping three near-identical components would have been three ways to get one thing wrong.
| Warning | Error |
|---|---|
![]() |
![]() |
AestheticSelectionDialog(
uiModel = SelectionDialogUiModel.Multiple(
title = "Notify me about",
items = uiState.filteredTopics, // you filter
selectedIds = uiState.selectedIds, // you own the selection
searchQuery = uiState.query, // you own the query
confirmLabel = "Save",
cancelLabel = "Cancel",
emptyText = "Nothing matches β${uiState.query}β.",
),
onItemClick = { id -> viewModel.toggleTopic(id) },
onCancel = { viewModel.dismissDialog() },
onSearchQueryChange = { viewModel.search(it) },
onConfirm = { viewModel.saveTopics() },
)The dialog renders and reports; it never filters, sorts or toggles. That is what lets the same component handle five static options and a remote search over ten thousand rows. Long lists are lazy, and the action row stays pinned.
| Single | Multiple, with search |
|---|---|
![]() |
![]() |
Text and Password variants. The field takes focus on open, the keyboard's
done action confirms, the dialog lifts above the keyboard, and the reveal toggle
survives rotation. Validation is yours: pass errorText and set
isConfirmEnabled.
AestheticInputDialog(
uiModel = InputDialogUiModel.Text(
title = "Rename album",
value = uiState.name,
label = "Album name",
errorText = uiState.nameError,
confirmLabel = "Rename",
cancelLabel = "Cancel",
isConfirmEnabled = uiState.nameError == null,
),
onValueChange = viewModel::onNameChange,
onConfirm = { viewModel.renameAlbum() },
onCancel = { viewModel.dismissDialog() },
)| Text | Invalid | Password |
|---|---|---|
![]() |
![]() |
![]() |
AestheticContentDialog(
uiModel = ContentDialogUiModel.Default(
title = "Before you continue",
primaryAction = DialogAction("I agree"),
secondaryAction = DialogAction("Not now", DialogActionEmphasis.Text),
),
onDismiss = { viewModel.dismissDialog() },
) {
ConsentSummary(uiState.consent)
}The middle is yours. The window, adaptive width, scrim, dismissal contract,
header, actions and accessibility pane stay with the design system β which is the
difference between an escape hatch and a raw Dialog {}.
Default (card) and Gradient (ramped panel), in all five tones. The ramp is
derived from the tone accent, so a rebranded theme keeps it consistent β and
warning and info finally have one.
AestheticFeedbackDialog(
uiModel = FeedbackDialogUiModel.Gradient(
title = "Message sent",
message = "It will arrive even if you close the app.",
tone = DialogTone.Success,
actionLabel = "Nice",
),
onDismiss = { viewModel.dismissDialog() },
)| Default | Gradient, success | Gradient, error |
|---|---|---|
![]() |
![]() |
![]() |
The edge-anchored 1.x styles are no longer dialogs. They were never modal in spirit, and rendering them as windows meant an informational toast dimmed the screen, stole focus and swallowed the back gesture.
Box(Modifier.fillMaxSize()) {
HomeContent()
AestheticNotificationHost(
notification = uiState.banner,
onDismiss = { viewModel.dismissBanner() },
onAction = { viewModel.undo() },
alignment = NotificationAlignment.Top,
animation = AestheticNotificationAnimation.Slide,
autoDismissMillis = 4_000,
)
}Five shapes:
| Default β tone bar down the leading edge | ![]() |
| Filled β tone-filled, inverted copy | ![]() |
| Gradient β ramped card | ![]() |
| Ambient β centred, under a tone rim | ![]() |
| Strip β docked, square, flat, never auto-dismissed | ![]() |
An emoji, a timestamp, a presence dot and a progress value are fields, not
shapes β Emoji and Emotion were 2.0 variants and are now
Default(emoji = "π") and Gradient(timestamp = "13:56"):
A trailing action β Undo, with a callback of its own |
![]() |
| Background progress, bonded to the bottom edge | ![]() |
| A leading slot and a presence dot, for banners from a person | ![]() |
| An emoji instead of the tone mark | ![]() |
Three behaviours belong to the host rather than to your screen:
AestheticNotificationHost(
notification = uiState.banner,
onDismiss = { viewModel.dismissBanner() },
queuePolicy = NotificationQueuePolicy.Enqueue, // Replace, Enqueue or Drop
swipeToDismiss = true, // a sideways drag asks to dismiss
showCountdown = true, // the delay, drawn as a draining hairline
)queuePolicy exists because an application emitting two banners in a row used to
lose one without saying so. The default is still Replace β stated now, rather
than accidental. Enqueue is the one case where the host holds a banner your
state no longer names; the queue is capped, and every callback still reaches you.
A Strip is docked instead of floated: flush against its edge, no margin, and its
auto-dismiss delay is ignored, because a condition is still true four seconds
later. Docked means it paints under the system bars β its copy is inset out of
the status bar, the cutout and the navigation bar, so the strip looks continuous
with the edge without printing its title over the clock.
It also carries no close affordance by default, for the same reason it has no
timer: a condition ends when it ends. Pass showCloseButton = true when the
condition is one the user is allowed to dismiss.
Banners are live regions, so screen readers announce them without the user having to go looking. Because the host owns nothing but the exit transition, they are the one place in the library with a full enter and exit animation:
Slide |
Fade |
Scale |
|---|---|---|
![]() |
![]() |
![]() |
Wrap once, inside your own theme:
MyAppTheme { // yours: untouched
AestheticDialogsTheme { // ours: four CompositionLocals, nothing else
AppNavHost()
}
}AestheticDialogsTheme installs no MaterialTheme. It provides the library's
own tokens and leaves your colour scheme, type scale and shapes exactly as they
were β so wrapping your whole application is safe, and your own composables
inside an AestheticContentDialog still look like yours. The handful of Material
components the library draws with are passed their colours explicitly.
Already have a brand? One line:
AestheticDialogsTheme(
colors = aestheticLightColors().withBrand(
primary = MaterialTheme.colorScheme.primary,
onPrimary = MaterialTheme.colorScheme.onPrimary,
),
) {
AppNavHost()
}withBrand takes plain Colors, not a Material ColorScheme, so Material 3
stays out of your compile classpath and out of this library's API. It moves the action colour, the
surface and the focus ring β and deliberately not the status tones: an error
has to look like an error in every application.
For finer control, copy the scheme:
AestheticDialogsTheme(
colors = aestheticLightColors().copy(
action = aestheticLightColors().action.copy(primary = BrandBlue),
),
shapes = AestheticShapes(dialog = RoundedCornerShape(4.dp)),
typography = AestheticTypography(title = MyBrandTitleStyle),
) {
AppNavHost()
}Precedence is library defaults β theme β UI model. Colour, type, shape and
motion are themeable because they express a brand; spacing and dimensions are not,
because they express the structure of the components.
Tokens are public and semantic: AestheticDialogsTheme.colors.status.error.accent,
AestheticSpacing.lg, AestheticDimens.minTouchTarget. Raw hues are internal, so
dark mode is a remapping rather than a second implementation.
No dynamic colour, deliberately: a design system exists so a warning looks like a warning.
| Alert | Selection | Banners |
|---|---|---|
![]() |
![]() |
![]() |
Component (public) AestheticConfirmationDialog β dispatches on the UI model
β
Variant (internal) ConfirmationDialogDestructive β resolves semantics
β
Primitive (internal) DialogFramePrimitive β window, scrim, width, a11y, layout
β
Tokens (public) AestheticColors, AestheticSpacing, AestheticMotion β¦
explicitApi() plus internal makes the boundary a compiler rule: consumers
cannot import a variant or a primitive.
Full detail in docs/ARCHITECTURE.md, including why modal
dialogs animate in but not out, why the selection model is not @Immutable, and
why two dialogs express their actions differently. The audit of 1.x that drove
the rewrite is in docs/ARCHITECTURE_AUDIT.md.
Not a checklist item β it is most of why the rewrite happened.
| Screen readers | paneTitle on every dialog, headings on titles, live regions on banners (assertive for errors) |
| Selection | Row-level selectable/toggleable with roles, so a row is announced once and correctly |
| Targets | Every interactive element at least 48dp |
| Text | All type in sp; the layouts most likely to break carry a 200% font-scale preview, and one is held by a screenshot baseline |
| Motion | Transitions become cuts when the platform animation scale is zero |
| Colour | Every tone carries a distinct drawn mark as well as a hue; accents clear 4.5:1 in both shipped schemes |
| Focus | The input dialog moves focus to its field on open |
The :app module is a component catalog that consumes the library through its
published API only β which makes it the cheapest test of whether that API is
sufficient. It covers every component, every tone, both themes, long content,
loading, empty and error states.
It is on Google Play, so you can hold the components before you depend on them:
Or build it yourself:
./gradlew :app:installDebug
./gradlew build # compile, lint, unit + interaction tests
./gradlew recordRoborazziDebug # record screenshot baselines
./gradlew verifyRoborazziDebug # check them
./gradlew apiDump # update api/aestheticdialogs.api after an API change
./gradlew spotlessApply # format
scripts/generate-docs-images.sh # redraw everything in docs/images
The documentation images are rendered on the JVM from the real components β no
emulator, no screen recorder, byte-identical on every machine. Adding a dialog
means adding a case to Docs*.kt in the library's test source set; the README and
this README then updates itself.
Adding a dialog or a variant: docs/ARCHITECTURE.md Β§12. General guidelines: CONTRIBUTING.md.
Copyright 2019 TEKOMBO Gabriel
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.





























