Guide for implementing native context menus in Tauri v2 applications. This skill documents the correct approach and common pitfalls when integrating native menus with a React frontend.
When to Apply
Reference this guide when:
- Adding right-click context menus to UI elements
- Implementing popup menus in Tauri v2
- Migrating from web-based menus to native menus
- Debugging menu event handling issues
Critical Knowledge
Do NOT Use muda Directly
Tauri v2 uses muda internally for menu management, but do not use muda's event system directly in a Tauri application.
Why it fails:
muda::MenuEvent::set_event_handler() does not receive events in Tauri's event loop
muda::MenuEvent::receiver() channel never receives menu click events
- Tauri manages its own event loop and intercepts muda events
What happens:
rust
1// This will NOT work - events never fire
2muda::MenuEvent::set_event_handler(Some(|event| {
3 // This closure is never called in Tauri context
4}));
5
6// This also does NOT work
7let receiver = muda::MenuEvent::receiver();
8if let Ok(event) = receiver.try_recv() {
9 // Never receives events
10}
Instead, use Tauri's built-in menu API which properly integrates with its event system:
rust
1use tauri::menu::{MenuBuilder, MenuItem, PredefinedMenuItem};
2use tauri::Window;
3
4#[tauri::command]
5pub async fn show_context_menu(
6 window: Window,
7 request: ShowContextMenuRequest,
8) -> Result<(), String> {
9 // Build menu using Tauri's API
10 let mut menu_builder = MenuBuilder::new(&window);
11
12 for item in &request.items {
13 match item {
14 ContextMenuItemOrSeparator::Item(menu_item) => {
15 // Use compound ID format: "ctx:{request_id}:{item_id}"
16 let compound_id = format!("ctx:{}:{}", request.request_id, menu_item.id);
17 let tauri_item = MenuItem::with_id(
18 &window,
19 &compound_id,
20 &menu_item.label,
21 !menu_item.disabled.unwrap_or(false),
22 None::<&str>,
23 ).map_err(|e| e.to_string())?;
24 menu_builder = menu_builder.item(&tauri_item);
25 }
26 ContextMenuItemOrSeparator::Separator => {
27 let separator = PredefinedMenuItem::separator(&window)
28 .map_err(|e| e.to_string())?;
29 menu_builder = menu_builder.item(&separator);
30 }
31 }
32 }
33
34 let menu = menu_builder.build().map_err(|e| e.to_string())?;
35
36 // Show as popup menu at cursor position
37 window.popup_menu(&menu).map_err(|e| e.to_string())?;
38
39 Ok(())
40}
Handle Events in App Setup
Register a global menu event handler in your lib.rs setup:
rust
1// In tauri::Builder::default().setup(|app| { ... })
2app.on_menu_event(move |app, event| {
3 let event_id = event.id().0.as_str();
4
5 // Handle context menu events (format: "ctx:{request_id}:{item_id}")
6 if event_id.starts_with("ctx:") {
7 let parts: Vec<&str> = event_id.splitn(3, ':').collect();
8 if parts.len() == 3 {
9 let request_id = parts[1];
10 let item_id = parts[2];
11 app.emit(
12 "context-menu:clicked",
13 serde_json::json!({
14 "requestId": request_id,
15 "itemId": item_id,
16 }),
17 ).ok();
18 }
19 return;
20 }
21
22 // Handle other menu events...
23});
Frontend Event Handling
Listen for context menu events in React:
tsx
1import { listen } from "@tauri-apps/api/event"
2
3// Store a stable requestId per component instance
4const requestIdRef = useRef(`file-row-${file.path}-${Date.now()}`)
5const handlersRef = useRef<Map<string, () => void>>(new Map())
6
7// Set up handlers map
8useEffect(() => {
9 const handlers = new Map<string, () => void>()
10 handlers.set("action-1", handleAction1)
11 handlers.set("action-2", handleAction2)
12 handlersRef.current = handlers
13}, [handleAction1, handleAction2])
14
15// Listen for context menu events
16useEffect(() => {
17 const requestId = requestIdRef.current
18 const unlisten = listen<{ requestId: string; itemId: string }>(
19 "context-menu:clicked",
20 (event) => {
21 if (event.payload.requestId === requestId) {
22 const handler = handlersRef.current.get(event.payload.itemId)
23 if (handler) handler()
24 }
25 },
26 )
27 return () => {
28 unlisten.then((fn) => fn())
29 }
30}, [])
31
32// Trigger context menu on right-click
33const handleContextMenu = useCallback(async (e: React.MouseEvent) => {
34 e.preventDefault()
35 e.stopPropagation()
36
37 await showContextMenu({
38 requestId: requestIdRef.current,
39 items: [
40 { type: "item", id: "action-1", label: "Action 1" },
41 { type: "separator" },
42 { type: "item", id: "action-2", label: "Action 2" },
43 ],
44 })
45}, [])
Use Cursor Position (Recommended)
Let the OS handle menu positioning by not specifying coordinates:
rust
1// Pass None for position - menu appears at cursor
2window.popup_menu(&menu)?;
Avoid Manual Coordinate Conversion
Do NOT try to convert frontend coordinates to screen coordinates manually:
rust
1// DON'T DO THIS - coordinate systems differ between:
2// - CSS pixels (logical) vs physical pixels
3// - Window-relative vs screen-relative
4// - Different scale factors on Retina displays
5let screen_x = window_pos.x + client_x; // WRONG
TypeScript Types
typescript
1export interface ContextMenuItem {
2 id: string
3 label: string
4 disabled?: boolean
5}
6
7export type ContextMenuItemOrSeparator =
8 | { type: "item"; id: string; label: string; disabled?: boolean }
9 | { type: "separator" }
10
11export interface ShowContextMenuRequest {
12 requestId: string
13 items: ContextMenuItemOrSeparator[]
14 x?: number // Optional, not used when relying on cursor position
15 y?: number
16}
Rust Types
rust
1#[derive(Debug, Clone, Serialize, Deserialize)]
2pub struct ContextMenuItem {
3 pub id: String,
4 pub label: String,
5 pub disabled: Option<bool>,
6}
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(tag = "type")]
10pub enum ContextMenuItemOrSeparator {
11 #[serde(rename = "item")]
12 Item(ContextMenuItem),
13 #[serde(rename = "separator")]
14 Separator,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")] // Important: match frontend naming
19pub struct ShowContextMenuRequest {
20 pub request_id: String,
21 pub items: Vec<ContextMenuItemOrSeparator>,
22 pub x: Option<f64>,
23 pub y: Option<f64>,
24}
Common Mistakes
| Mistake | Why It Fails | Solution |
|---|
Using muda::MenuEvent::set_event_handler() | Tauri intercepts muda events | Use app.on_menu_event() |
Using muda::MenuEvent::receiver() | Events never delivered to channel | Use app.on_menu_event() |
| Manual coordinate conversion | CSS vs physical pixels, scale factors | Let OS position at cursor |
Missing #[serde(rename_all = "camelCase")] | Frontend sends camelCase, Rust expects snake_case | Add serde attribute |
Using &AppHandle in MenuItem::with_id() | Borrow doesn't implement Manager | Use &window instead |
Dependencies
toml
1# Cargo.toml - muda is NOT needed as a direct dependency
2# Tauri v2 includes it internally
3
4[dependencies]
5tauri = { version = "2", features = ["menu"] }
6serde = { version = "1", features = ["derive"] }
7serde_json = "1"
References