Compose Unstyled 2.0 is out! Check the official announcement blog ->

Dropdown Menu

An anchored menu component with keyboard navigation and custom placement.

import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.Clipboard
import com.composables.icons.lucide.Copy
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Maximize
import com.composables.icons.lucide.Scissors
import com.composables.icons.lucide.Trash2
import com.composeunstyled.DropdownMenuPanel
import com.composeunstyled.LocalContentColor
import com.composeunstyled.MenuItem
import com.composeunstyled.UnstyledButton
import com.composeunstyled.UnstyledDropdownMenu
import com.composeunstyled.UnstyledHorizontalSeparator
import com.composeunstyled.UnstyledIcon

@Composable
fun DropdownMenuDemo() {
  class DropdownOption(
    val text: String,
    val icon: ImageVector,
    val enabled: Boolean = true,
    val dangerous: Boolean = false,
  )

  val options = listOf(
    DropdownOption("Select All", Lucide.Maximize),
    DropdownOption("Copy", Lucide.Copy),
    DropdownOption("Cut", Lucide.Scissors, enabled = false),
    DropdownOption("Paste", Lucide.Clipboard),
    DropdownOption("Delete", Lucide.Trash2, dangerous = true),
  )
  var expanded by remember { mutableStateOf(true) }

  UnstyledDropdownMenu(
    expanded = expanded,
    onExpandedChange = { expanded = it },
    sideOffset = 4.dp,
    panel = {
      DropdownMenuPanel(
        modifier = Modifier
          .width(240.dp)
          .clip(RoundedCornerShape(8.dp))
          .background(Color(0xFFF8FAFC))
          .border(1.dp, Color(0xFFCACACA), RoundedCornerShape(8.dp)),
        enter = scaleIn(
          animationSpec = tween(durationMillis = 120, easing = LinearOutSlowInEasing),
          initialScale = 0.8f,
          transformOrigin = TransformOrigin(0f, 0f),
        ) + fadeIn(tween(durationMillis = 30)),
        exit = scaleOut(
          animationSpec = tween(durationMillis = 75),
          targetScale = 0.8f,
          transformOrigin = TransformOrigin(0f, 0f),
        ) +
          fadeOut(tween(durationMillis = 75)),
      ) {
        Column {
          options.forEachIndexed { index, option ->
            if (index == 1 || index == options.lastIndex) {
              UnstyledHorizontalSeparator(color = Color(0xFFBDBDBD))
            }
            MenuItem(
              onClick = {},
              enabled = option.enabled,
              modifier = Modifier
                .padding(4.dp)
                .sizeIn(minWidth = 40.dp, minHeight = 40.dp)
                .clip(RoundedCornerShape(8.dp))
                .fillMaxWidth(),
              indication = LocalIndication.current,
            ) {
              Row(
                modifier = Modifier
                  .fillMaxWidth()
                  .padding(horizontal = 8.dp, vertical = 8.dp),
                horizontalArrangement = Arrangement.Start,
                verticalAlignment = Alignment.CenterVertically,
              ) {
                val contentColor = (
                  if (option.dangerous) {
                    Color(0xFFDC2626)
                  } else {
                    LocalContentColor.current
                  }
                  ).copy(alpha = if (option.enabled) 1f else 0.5f)

                UnstyledIcon(
                  imageVector = option.icon,
                  contentDescription = null,
                  tint = contentColor,
                )
                Spacer(Modifier.width(12.dp))
                BasicText(
                  text = option.text,
                  style = TextStyle(color = contentColor),
                )
              }
            }
          }
        }
      }
    },
    anchor = {
      UnstyledButton(
        onClick = { expanded = true },
        modifier = Modifier
          .sizeIn(minWidth = 40.dp, minHeight = 40.dp)
          .clip(RoundedCornerShape(6.dp))
          .background(Color(0xFFF8FAFC))
          .border(1.dp, Color(0xFFCACACA), RoundedCornerShape(6.dp)),
        indication = LocalIndication.current,
      ) {
        Row(
          modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
          verticalAlignment = Alignment.CenterVertically,
        ) {
          BasicText("Options")
          Spacer(Modifier.width(8.dp))
          UnstyledIcon(Lucide.ChevronDown, null)
        }
      }
    },
  )
}

Features

  • Anchor-based placement
  • Keyboard menu navigation
  • Auto-dismiss on outside click
  • Panel enter and exit transitions

Installation

implementation("com.composables:composeunstyled-dropdown-menu")

Anatomy

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = onExpandedChange,
  panel = {
    DropdownMenuPanel {
      MenuItem(onClick = onClick) {
      }
    }
  },
  anchor = {
  },
)

Concepts

  • UnstyledDropdownMenu marks the anchor area and renders the floating menu when expanded.
  • The anchor slot renders the content the menu is positioned against.
  • The panel slot renders the floating menu content.
  • DropdownMenuPanel renders the menu surface.
  • MenuItem renders an item inside DropdownMenuPanel.

Accessibility

Dropdown menu handles keyboard interactions out of the box. Pressing Arrow Down opens the menu and focuses the first item. Pressing Arrow Down and Arrow Up moves focus to the next and previous item. Home moves focus to the first item. End moves focus to the last item. Escape closes the menu. Use MenuItem for focusable menu actions.

Code Examples

Opening and closing a dropdown menu

Use the expanded parameter to show the menu and the onExpandedChange callback to update that state:

var expanded by remember { mutableStateOf(false) }

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = { expanded = it },
  panel = {
    DropdownMenuPanel {
      MenuItem(onClick = { expanded = false }) {
        BasicText("Close")
      }
    }
  },
  anchor = {
    BasicText(
      text = "Open menu",
      modifier = Modifier.clickable { expanded = true },
    )
  },
)

Positioning a dropdown menu

Use the side, alignment, sideOffset, and alignmentOffset parameters to place the menu panel relative to the anchor:

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = { expanded = it },
  side = AnchorSide.Bottom,
  alignment = AnchorAlignment.End,
  sideOffset = 8.dp,
  panel = {
    DropdownMenuPanel {
      MenuItem(onClick = { select() }) {
        BasicText("Item")
      }
    }
  },
  anchor = {
    BasicText("Open menu")
  },
)

Closing the menu after clicking an item

MenuItem closes the menu after click by default by calling the dropdown's onExpandedChange callback with false:

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = { expanded = it },
  panel = {
    DropdownMenuPanel {
      MenuItem(onClick = { select() }) {
        BasicText("Item")
      }
    }
  },
  anchor = {
    BasicText("Open menu")
  },
)

Keeping the menu open after clicking an item

Use the closeOnClick parameter when a menu item should update state without dismissing the menu:

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = { expanded = it },
  panel = {
    DropdownMenuPanel {
      MenuItem(
        closeOnClick = false,
        onClick = { enabled = enabled.not() },
      ) {
        BasicText("Toggle option")
      }
    }
  },
  anchor = {
    BasicText("Open menu")
  },
)

Animating the dropdown menu

Use the enter and exit parameters on DropdownMenuPanel to animate the menu panel:

UnstyledDropdownMenu(
  expanded = expanded,
  onExpandedChange = { expanded = it },
  panel = {
    DropdownMenuPanel(
      enter = fadeIn(),
      exit = fadeOut(),
    ) {
      MenuItem(onClick = { select() }) {
        BasicText("Item")
      }
    }
  },
  anchor = {
    BasicText("Open menu")
  },
)

API Reference

UnstyledDropdownMenu

Parameter Type Description
expanded Boolean
onExpandedChange (Boolean) -> Unit
modifier Modifier
side AnchorSide
alignment AnchorAlignment
sideOffset Dp
alignmentOffset Dp
panel DropdownMenuScope.() -> Unit
anchor () -> Unit
Parameter Type Description
modifier Modifier
enter EnterTransition
exit ExitTransition
content DropdownMenuPanelScope.() -> Unit
Parameter Type Description
onClick () -> Unit
modifier Modifier
enabled Boolean
closeOnClick Boolean
interactionSource MutableInteractionSource?
indication Indication?
content () -> Unit