Skip to content

Repository files navigation

AestheticDialogs β€” a Jetpack Compose dialog design system

AestheticDialogs

Maven Central API Kotlin Compose License

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.


Contents


Why it exists

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.

Install

// 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.


Quick start

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.


The dialogs

Every image below is rendered from the real component by scripts/generate-docs-images.sh, so it cannot drift from the library.

Confirmation β€” ask before doing something

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
Default confirmation Destructive confirmation Confirmation in progress

Alert β€” tell them something, offer a way forward

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
Warning alert Error alert

Selection β€” pick one, or several

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
Single selection Multiple selection

Input β€” ask for one value

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
Text input Input with an error Password input

Rich content β€” your content, our frame

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 {}.

Rich content dialog

Feedback β€” the 1.x dialogs, rebuilt

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
Default feedback Gradient feedback Gradient feedback, error

Notifications

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 Default banner
Filled β€” tone-filled, inverted copy Filled banner
Gradient β€” ramped card Gradient banner
Ambient β€” centred, under a tone rim Ambient banner
Strip β€” docked, square, flat, never auto-dismissed Status strip

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 Banner with an action
Background progress, bonded to the bottom edge Banner with progress
A leading slot and a presence dot, for banners from a person Message banner
An emoji instead of the tone mark Emoji banner

What the host owns

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
Slide Fade Scale

Theming

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
Alert, dark Selection, dark Banners, dark

Architecture

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.


Accessibility

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

Catalog

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:

Get it on Google Play

Or build it yourself:

./gradlew :app:installDebug

Contributing

./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.


License

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.

About

πŸ“± An Android Library built with Jetpack Compose for πŸ’« fluid, 😍 beautiful, 🎨 custom Dialogs.

Topics

Resources

Contributing

Stars

630 stars

Watchers

15 watching

Forks

Releases

Packages

Contributors

Languages