Kotlin Desktop Dialogs & Menus: AlertDialog, DropdownMenu & MenuBar | Tutorial #7
Video: Kotlin Desktop Dialogs & Menus: AlertDialog, DropdownMenu & MenuBar | Tutorial #7 by Taught by Celeste AI - AI Coding Coach
Dialogs and Menus in Compose Desktop
AlertDialogfor the standard case. Custom inline overlays for screenshot-testable dialogs.DropdownMenufor action menus.
Today's lesson covers the secondary surfaces every desktop app needs: dialogs and dropdown menus. Material 3's AlertDialog is the standard primitive. For tested-by-screenshot apps (this lesson uses a UI-testing harness that captures the framebuffer) we also build inline dialogs that don't use platform popups.
What we are building
A dashboard with three buttons and a more-options icon:
- About — opens an alert dialog with informational text and a Cancel / OK pair.
- Add Item — opens an input dialog with a text field and a Save / Cancel pair.
- Remove Last — removes the last item from a list (no dialog; just direct action).
- More options (⋮) — opens a dropdown menu with "Clear All" and "Reset" actions.
Below: a status line that updates with the latest action, and the current items list.
Why inline dialogs
Material 3's AlertDialog and DropdownMenu use platform popups. On macOS and Windows they appear in their own native windows, which is the right user experience — but it means a screenshot of your app's main window doesn't capture them. For UI tests that take screenshots and compare them, you need overlays that render inside the main composition.
This lesson uses both: a standard AlertDialog for the delete-confirmation, and custom InlineDialog and InlineDropdownMenu Composables for the others. Real apps usually pick one approach and stick with it.
InlineDialog
@Composable
fun InlineDialog(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onDismiss,
),
contentAlignment = Alignment.Center,
) {
Card(
modifier = modifier
.width(420.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {},
),
shape = MaterialTheme.shapes.extraLarge,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) {
content()
}
}
}
Three layers.
The outer Box is a full-screen scrim — semi-transparent black overlay that dims the background and intercepts clicks outside the dialog. Clicking the scrim calls onDismiss.
The inner Card is the dialog body. It also has a clickable modifier with onClick = {} — this swallows clicks so they don't bubble up to the scrim and accidentally dismiss. Standard modal trick.
indication = null and interactionSource = remember { MutableInteractionSource() } disable the ripple animation that clickable applies by default. We don't want a ripple on the scrim or on the card body.
Using InlineDialog
if (showAlertDialog) {
InlineDialog(onDismiss = { showAlertDialog = false }) {
Column(modifier = Modifier.padding(24.dp)) {
Text("About This App", style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(16.dp))
Text("This is a Compose Desktop app demonstrating dialogs and menus.")
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showAlertDialog = false }) { Text("Cancel") }
Spacer(Modifier.width(8.dp))
TextButton(onClick = { showAlertDialog = false }) { Text("OK") }
}
}
}
}
The if guards visibility — when showAlertDialog is true, the Composable renders; when false, it doesn't exist in the tree. Standard immediate-mode show/hide.
Arrangement.End right-aligns the buttons. TextButton is the Material 3 idiom for dialog actions — flat, no fill, just a tinted label.
InlineDropdownMenu
@Composable
fun InlineDropdownMenu(
expanded: Boolean,
onDismiss: () -> Unit,
anchor: @Composable () -> Unit,
content: @Composable ColumnScope.() -> Unit,
) {
Box {
anchor()
if (expanded) {
Card(
modifier = Modifier.padding(top = 48.dp).width(180.dp),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
content()
}
}
}
}
}
The anchor slot holds whatever triggers the menu (typically a button). The Box lets the menu Card sit on top of the anchor with padding(top = 48.dp) to drop below it.
InlineDropdownMenu(
expanded = showDropdown,
onDismiss = { showDropdown = false },
anchor = {
IconButton(onClick = { showDropdown = !showDropdown }) {
Icon(Icons.Default.MoreVert, "More options")
}
},
) {
TextButton(
onClick = { items = emptyList(); showDropdown = false },
modifier = Modifier.fillMaxWidth(),
) {
Text("Clear All")
}
TextButton(
onClick = { items = listOf("Item 1", "Item 2", "Item 3"); showDropdown = false },
modifier = Modifier.fillMaxWidth(),
) {
Text("Reset")
}
}
Click the ⋮ button → showDropdown toggles. The menu appears with two TextButton children. Click an item — execute its action, set showDropdown = false.
Standard AlertDialog
For the delete confirmation we use Material 3's built-in dialog:
deletingContact?.let { c ->
AlertDialog(
onDismissRequest = { deletingContact = null },
title = { Text("Delete Contact") },
text = { Text("Delete ${c.name}?") },
confirmButton = {
TextButton(onClick = { /* delete */; deletingContact = null }) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { deletingContact = null }) { Text("Cancel") }
},
)
}
AlertDialog takes named slots: title, text, confirmButton, dismissButton. Material handles the layout, scrim, focus management, keyboard escape — all the polish.
For most apps, this is the right default. Reach for InlineDialog only when you need rendering inside the main composition (e.g., screenshot tests).
Status updates
var statusText by remember { mutableStateOf("Click a button to open a dialog") }
// inside button onClick:
statusText = "Alert dialog confirmed"
A simple status line that updates after every action. Useful for testing — your test verifies the status text instead of poking at internal state.
Common patterns
Confirm-on-destructive. The Delete button shows a dialog before deleting. Standard for any irreversible action.
Form-in-dialog. The Add/Edit dialog has a text field; the Save button validates (if (name.isNotBlank())) before committing.
Primary action via Enter, Cancel via Escape. Add keyboard handling for accessibility. Compose's Modifier.onKeyEvent plus Key.Enter / Key.Escape handles it.
Click-outside-to-dismiss. Both the scrim approach and AlertDialog's onDismissRequest make this automatic. Don't override unless you have a strong reason.
Common mistakes
Forgetting to swallow clicks on the dialog body. Without the clickable {} on the inner Card, clicking the dialog content dismisses it (because the scrim's onClick fires).
Showing dialog with if, not via state. A dialog whose visibility you control via showDialog boolean is correct. A dialog inside a function that "imperatively pops" doesn't work in Compose's declarative model.
Hardcoding dialog widths. A 300dp dialog on a 4K screen looks tiny. Use Modifier.fillMaxWidth(0.5f) or sensible breakpoints.
Long dialogs without scroll. A form with 20 fields in a dialog overflows the screen on smaller windows. Wrap the body in Modifier.verticalScroll(rememberScrollState()).
Multiple dialogs at once. Modal-on-modal is confusing. Close the parent dialog before opening the child, or use a stepped flow.
What's next
Lesson 8: HTTP and APIs. Build a Weather App using Ktor Client to call a real REST API, with kotlinx.serialization parsing the JSON response.
Recap
AlertDialog is the standard Material 3 primitive — title, text, confirm/dismiss slots. Custom InlineDialog for cases where you need rendering inside the main composition. DropdownMenu (or custom InlineDropdownMenu) for action menus. Visibility via if (showDialog) { ... } driven by mutableStateOf<Boolean>. Suppress ripple on overlays with indication = null.
Next lesson: HTTP and APIs.