
If you've ever needed to let users pick from a list of options in an Android app, Jetpack Compose ExposedDropdownMenu is the composable you want. It gives you a Material Design dropdown that feels polished, works cleanly with state, and integrates directly into a TextField so users always see their current selection. This guide walks through everything from the basic setup to building a fully interactive Compose dropdown with custom items and searchable filtering.
Jetpack Compose ExposedDropdownMenu lives inside the Material3 library, so your project needs the right dependency before you write a single line of UI code. Open your module-level build.gradle and make sure you have this:
dependencies {
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.runtime:runtime:1.7.6")
implementation("androidx.activity:activity-compose:1.9.3")
}
The key package here is androidx.compose.material3. This is where ExposedDropdownMenuBox, ExposedDropdownMenu, and DropdownMenuItem all live. If you're starting a fresh project in Android Studio, the Material3 template includes this automatically.
You'll also want to enable Compose in your build config if it's not already set:
android {
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.10"
}
}
The foundation of Jetpack Compose ExposedDropdownMenu is a composable called ExposedDropdownMenuBox. Think of it as a smart container — it manages the expanded and collapsed state of your Android Compose dropdown, coordinates the position of the menu relative to the anchor, and exposes a scope that child composables use to wire themselves together.
Inside that scope you place two main things: a TextField that acts as the visible anchor the user taps to open the list, and an ExposedDropdownMenu that holds the actual list items.
The scope provides the menuAnchor modifier for the TextField, which tells the system exactly where the dropdown should pop out from. Without that modifier the dropdown has no reference point and won't position itself correctly. Here is the bare skeleton so you can see the relationship:
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.OutlinedTextField
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.Modifier
@Composable
fun SimpleDropdownSkeleton() {
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = "Pick an option",
onValueChange = {},
readOnly = true,
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Option A") },
onClick = { expanded = false }
)
}
}
}
When you run this, a text field appears on screen. Tapping it opens a small menu below it showing "Option A". Tapping anywhere outside dismisses it cleanly.
The skeleton above hardcodes a single item, which is not useful in real apps. In practice you'll have a list of options and you want the selected one to display inside the TextField. Here is a more realistic version where users choose a programming language from a Compose dropdown list:
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
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.Modifier
@Composable
fun LanguageDropdown() {
val languages = listOf("Kotlin", "Java", "Python", "Swift", "TypeScript")
var expanded by remember { mutableStateOf(false) }
var selectedLanguage by remember { mutableStateOf(languages[0]) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selectedLanguage,
onValueChange = {},
readOnly = true,
label = { Text("Programming Language") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
languages.forEach { language ->
DropdownMenuItem(
text = { Text(language) },
onClick = {
selectedLanguage = language
expanded = false
}
)
}
}
}
}
A few things here are worth understanding one by one.
Setting readOnly to true on the OutlinedTextField tells the system this field is not for typing — it just displays the current selection. The onValueChange callback is still required by the API, but since users are not typing you can leave it as an empty lambda.
ExposedDropdownMenuDefaults.TrailingIcon is a small animated chevron icon built into Material3. Pass it the expanded state and it automatically rotates to point upward when the menu is open and back down when it closes. This gives users a clear visual cue that the field is interactive.
ExposedDropdownMenuDefaults.outlinedTextFieldColors applies the standard Material3 color scheme designed specifically for this Android Compose spinner pattern, so the field matches your theme without any extra work.
The forEach loop over the languages list generates one DropdownMenuItem per option. When the user taps an item, onClick sets selectedLanguage to the tapped value and closes the menu by flipping expanded back to false.
Compose state management for a dropdown relies on two pieces of state: whether the menu is currently expanded, and which option the user has selected. These are separate concerns and you should treat them that way.
Using remember with mutableStateOf is the right pattern for state that belongs to a single composable. This keeps the state tied to the composable's lifecycle — when the composable leaves the composition the state is discarded, which is exactly what you want for UI-only state like whether a dropdown is open or closed.
If you need the selected value to survive configuration changes like screen rotation, or if multiple composables need to read the same selection, lift the state into a ViewModel:
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class DropdownViewModel : ViewModel() {
private val _selectedOption = MutableStateFlow("Kotlin")
val selectedOption: StateFlow<String> = _selectedOption
fun onOptionSelected(option: String) {
_selectedOption.value = option
}
}
Now wire that ViewModel into your Jetpack Compose ExposedDropdownMenu composable:
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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.lifecycle.viewmodel.compose.viewModel
@Composable
fun DropdownWithViewModel(vm: DropdownViewModel = viewModel()) {
val options = listOf("Kotlin", "Java", "Python", "Swift", "TypeScript")
val selectedOption by vm.selectedOption.collectAsState()
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selectedOption,
onValueChange = {},
readOnly = true,
label = { Text("Language") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
vm.onOptionSelected(option)
expanded = false
}
)
}
}
}
}
The selected value now lives in the ViewModel and survives screen rotations. The expanded state intentionally stays local inside the composable because there is no reason to persist whether a dropdown is open across the app.
DropdownMenuItem accepts leadingContent and trailingContent parameters that let you slot composables alongside the label text. This is great when your options represent statuses, categories, or anything that benefits from a visual indicator:
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Circle
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.graphics.Color
data class StatusOption(val label: String, val color: Color)
@Composable
fun StatusDropdown() {
val statuses = listOf(
StatusOption("Active", Color(0xFF4CAF50)),
StatusOption("Pending", Color(0xFFFFC107)),
StatusOption("Inactive", Color(0xFFF44336))
)
var expanded by remember { mutableStateOf(false) }
var selected by remember { mutableStateOf(statuses[0]) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selected.label,
onValueChange = {},
readOnly = true,
label = { Text("Status") },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = null,
tint = selected.color
)
},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
statuses.forEach { status ->
DropdownMenuItem(
text = { Text(status.label) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Circle,
contentDescription = null,
tint = status.color
)
},
onClick = {
selected = status
expanded = false
}
)
}
}
}
}
Each dropdown item shows a colored circle before the label. When the user selects an option, the circle color inside the TextField updates immediately to match — giving instant visual feedback about the current dropdown selection in Android without any extra logic.
A plain Compose dropdown list works well for short collections. When your list has dozens of items, letting users type to filter is a far better experience. Jetpack Compose ExposedDropdownMenu supports this pattern naturally — you switch the TextField from readOnly to editable and filter the displayed items based on what the user types:
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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
@Composable
fun SearchableCountryDropdown() {
val allCountries = listOf(
"Australia", "Brazil", "Canada", "Denmark", "Egypt",
"France", "Germany", "Hungary", "India", "Japan",
"Kenya", "Luxembourg", "Mexico", "Norway", "Oman"
)
var query by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }
val filtered by remember(query) {
derivedStateOf {
if (query.isBlank()) allCountries
else allCountries.filter { it.contains(query, ignoreCase = true) }
}
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it }
) {
OutlinedTextField(
value = query,
onValueChange = {
query = it
expanded = true
},
label = { Text("Search Country") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier.menuAnchor()
)
if (filtered.isNotEmpty()) {
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
filtered.forEach { country ->
DropdownMenuItem(
text = { Text(country) },
onClick = {
query = country
expanded = false
}
)
}
}
}
}
}
The key difference from the previous examples is that onValueChange now actively updates the query state and keeps the menu expanded as the user types. The filtered list is computed using derivedStateOf, which tells Compose to only recompute the filtered list when query actually changes — not on every unrelated recomposition. This keeps the Android UI components responsive even as the list narrows down.
When the user types "an" the list narrows to countries containing that sequence — Canada, France, Germany, and so on. Selecting a country fills the field and closes the menu cleanly.
This complete example combines everything covered above into a themed Material3 screen. It shows a Jetpack Compose ExposedDropdownMenu for selecting a subscription plan, with multi-line DropdownMenuItem content and a Card below that updates in real time to display the selected plan's details. All imports are included so you can drop this directly into a new Compose project.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
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.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenu
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.lightColorScheme
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
data class Plan(val name: String, val price: String, val description: String)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = lightColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) {
SubscriptionPlanScreen()
}
}
}
}
}
@Composable
fun SubscriptionPlanScreen() {
val plans = listOf(
Plan("Free", "$0/month", "5 projects, 1 GB storage, community support"),
Plan("Pro", "$12/month", "Unlimited projects, 50 GB storage, email support"),
Plan("Team", "$39/month", "Everything in Pro plus team collaboration tools"),
Plan("Enterprise", "$99/month", "Custom limits, SLA, dedicated account manager")
)
var expanded by remember { mutableStateOf(false) }
var selectedPlan by remember { mutableStateOf(plans[0]) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Choose Your Plan",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = selectedPlan.name,
onValueChange = {},
readOnly = true,
label = { Text("Subscription Plan") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
},
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
modifier = Modifier
.fillMaxWidth()
.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
plans.forEach { plan ->
DropdownMenuItem(
text = {
Column {
Text(
text = plan.name,
fontWeight = FontWeight.SemiBold
)
Text(
text = plan.price,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary
)
}
},
onClick = {
selectedPlan = plan
expanded = false
}
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = selectedPlan.name,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Text(
text = selectedPlan.price,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp)
)
Text(
text = selectedPlan.description,
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}