
When building Android apps with Jetpack Compose, dropdown menus are one of the most common UI patterns you will run into. Whether you are working on a settings screen, a filter panel, or a language selector, the Jetpack Compose DropdownMenu gives you a clean, declarative way to show a list of choices that pops up on demand. In this guide you will learn how DropdownMenu works under the hood, how to control its open and close state, how to capture what the user selects, how to add icons, and how to use Material3's ExposedDropdownMenuBox for form-style inputs. Every example is fully runnable and built from scratch.
The Jetpack Compose DropdownMenu is a composable that renders a floating popup list anchored to another composable on screen. It appears when triggered — usually by tapping a button — and disappears when the user makes a selection or taps outside of it.
Unlike traditional Android XML-based menus that rely on PopupMenu or Spinner adapters, the Compose DropdownMenu is fully declarative. You control its visibility through a single Boolean state variable. When that variable is true the menu appears; when it is false the menu disappears. Compose handles the animation and layout positioning automatically.
This is what makes the Compose dropdown so powerful for beginners — there are no listeners to register, no adapters to wire up, and no XML layouts to manage. Everything lives in one composable function and reacts to state changes instantly.
The simplest Jetpack Compose DropdownMenu needs just two things: an anchor composable like a button, and a mutableStateOf Boolean that controls whether the menu is visible. You pass that Boolean to the expanded parameter, and you provide an onDismissRequest callback that sets it back to false when the user taps outside.
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun BasicDropdownExample() {
var expanded by remember { mutableStateOf(false) }
Button(onClick = { expanded = true }) {
Text("Open Menu")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Profile") },
onClick = { expanded = false }
)
DropdownMenuItem(
text = { Text("Settings") },
onClick = { expanded = false }
)
DropdownMenuItem(
text = { Text("Sign Out") },
onClick = { expanded = false }
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewBasicDropdown() {
BasicDropdownExample()
}
When the button is tapped, expanded becomes true and the DropdownMenu renders its floating popup immediately. Each DropdownMenuItem sets expanded back to false when clicked, which collapses the menu. The onDismissRequest does the same when the user taps anywhere outside the popup. This is the entire lifecycle of a Compose dropdown — open, choose, close.
One thing beginners often get wrong with the Jetpack Compose DropdownMenu is placement. The menu is positioned relative to whichever composable it is placed inside. The standard pattern is to wrap both your button and the DropdownMenu inside a Box so that Compose knows exactly where to anchor the floating popup.
Without the Box wrapper, the menu may appear at an unexpected position on screen. With it, the menu anchors correctly just below or above the button depending on available screen space.
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun AnchoredDropdownExample() {
var expanded by remember { mutableStateOf(false) }
Box {
Button(onClick = { expanded = !expanded }) {
Text("Sort By")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Name") },
onClick = { expanded = false }
)
DropdownMenuItem(
text = { Text("Date Modified") },
onClick = { expanded = false }
)
DropdownMenuItem(
text = { Text("File Size") },
onClick = { expanded = false }
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewAnchoredDropdown() {
AnchoredDropdownExample()
}
Placing both the trigger button and the DropdownMenu inside the same Box is the recommended anchoring approach and you will use it in almost every real-world Compose dropdown scenario.
A dropdown menu that does not remember what the user picked is rarely useful. You will almost always want to capture the selected value and display it somewhere — usually in the button label itself. You do this by adding a second state variable that holds the current selection and updating it inside each DropdownMenuItem's onClick callback.
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun SelectableDropdownExample() {
val colorOptions = listOf("Red", "Green", "Blue", "Purple", "Orange")
var expanded by remember { mutableStateOf(false) }
var selectedColor by remember { mutableStateOf(colorOptions[0]) }
Box {
Button(onClick = { expanded = true }) {
Text("Color: $selectedColor")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
colorOptions.forEach { color ->
DropdownMenuItem(
text = { Text(color) },
onClick = {
selectedColor = color
expanded = false
}
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSelectableDropdown() {
SelectableDropdownExample()
}
The selectedColor variable starts as the first item in the list. When the user picks a new color, selectedColor updates and Compose immediately re-composes the button label to show the new selection. The forEach loop keeps the code compact — you never need to manually write a DropdownMenuItem for each option in a Compose dropdown list.
The DropdownMenuItem composable accepts a leadingIcon and a trailingIcon slot. This is useful for showing visual indicators like a checkmark next to the currently selected item, or an icon describing the action. When the user can see a checkmark next to their active choice it makes the Compose dropdown menu feel more polished and intuitive.
import androidx.compose.foundation.layout.Box
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
@Composable
fun IconDropdownExample() {
val themes = listOf("Light", "Dark", "System Default")
var expanded by remember { mutableStateOf(false) }
var selectedTheme by remember { mutableStateOf("System Default") }
Box {
Button(onClick = { expanded = true }) {
Text("Theme: $selectedTheme")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
themes.forEach { theme ->
DropdownMenuItem(
text = { Text(theme) },
leadingIcon = {
if (theme == selectedTheme) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected"
)
}
},
onClick = {
selectedTheme = theme
expanded = false
}
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewIconDropdown() {
IconDropdownExample()
}
The leadingIcon slot renders a checkmark only when the current theme matches the selected one. Because Compose re-composes the entire menu when selectedTheme changes, the checkmark moves to the right item instantly with zero extra logic on your part. This is the reactive model of Compose working exactly as intended.
When you need a dropdown that looks like a text input field — the kind commonly seen in forms for selecting a country, a currency, or a category — Material3 provides ExposedDropdownMenuBox. This is a higher-level composable that pairs a TextField with a dropdown menu and handles layout, sizing, and accessibility automatically.
The key difference from a plain Jetpack Compose DropdownMenu is that ExposedDropdownMenuBox uses a TextField as the anchor and provides ExposedDropdownMenuDefaults helpers for the trailing arrow icon and field colors. You attach the field to the menu using Modifier.menuAnchor() so Compose knows which element to position the popup against.
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExposedDropdownExample() {
val countries = listOf("Canada", "Germany", "Japan", "Brazil", "Australia")
var expanded by remember { mutableStateOf(false) }
var selectedCountry by remember { mutableStateOf(countries[0]) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
TextField(
value = selectedCountry,
onValueChange = {},
readOnly = true,
label = { Text("Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
countries.forEach { country ->
DropdownMenuItem(
text = { Text(country) },
onClick = {
selectedCountry = country
expanded = false
}
)
}
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewExposedDropdown() {
ExposedDropdownExample()
}
Setting readOnly to true on the TextField prevents the software keyboard from appearing since the user is picking from a list rather than typing. The ExposedDropdownMenuDefaults.TrailingIcon automatically rotates the arrow icon when the menu opens, giving a clear visual cue that something is expanded. This is the go-to pattern for any dropdown that lives inside a form in your Android app.
Here is a complete, self-contained settings screen that combines everything covered in this guide. It uses a standard Jetpack Compose DropdownMenu with a checkmark icon for language selection, and a Material3 ExposedDropdownMenuBox for notification frequency. Both dropdowns manage their own independent state, and a summary line at the bottom re-composes automatically whenever either selection changes.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val languages = listOf("English", "French", "Spanish", "German", "Japanese")
val frequencies = listOf("Immediately", "Hourly", "Daily", "Weekly", "Never")
var languageExpanded by remember { mutableStateOf(false) }
var selectedLanguage by remember { mutableStateOf(languages[0]) }
var frequencyExpanded by remember { mutableStateOf(false) }
var selectedFrequency by remember { mutableStateOf(frequencies[0]) }
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "App Settings",
style = MaterialTheme.typography.headlineMedium
)
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Display Language",
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(8.dp))
Box {
Button(
onClick = { languageExpanded = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Language: $selectedLanguage")
}
DropdownMenu(
expanded = languageExpanded,
onDismissRequest = { languageExpanded = false }
) {
languages.forEach { language ->
DropdownMenuItem(
text = { Text(language) },
leadingIcon = {
if (language == selectedLanguage) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null
)
}
},
onClick = {
selectedLanguage = language
languageExpanded = false
}
)
}
}
}
}
}
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Notification Frequency",
style = MaterialTheme.typography.labelLarge
)
Spacer(modifier = Modifier.height(8.dp))
ExposedDropdownMenuBox(
expanded = frequencyExpanded,
onExpandedChange = { frequencyExpanded = !frequencyExpanded }
) {
TextField(
value = selectedFrequency,
onValueChange = {},
readOnly = true,
label = { Text("Frequency") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = frequencyExpanded)
},
colors = ExposedDropdownMenuDefaults.textFieldColors(),
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = frequencyExpanded,
onDismissRequest = { frequencyExpanded = false }
) {
frequencies.forEach { frequency ->
DropdownMenuItem(
text = { Text(frequency) },
onClick = {
selectedFrequency = frequency
frequencyExpanded = false
}
)
}
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Active settings: $selectedLanguage · Notify $selectedFrequency",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSettingsScreen() {
MaterialTheme {
SettingsScreen()
}
}