diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs
--- a/apps/gridcraft/src/native_menu.rs
+++ b/apps/gridcraft/src/native_menu.rs
@@ -143,6 +143,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
                 ("Evaluate Formula…", "dialog:evaluateFormula", None),
                 ("-", "", None),
                 ("Record Actions", "automate.recordActions", None),
+                ("Customize Keyboard…", "dialog:customizeKeyboard", None),
                 ("Command Palette…", "dialog:commandSearch", Some("CMD+SHIFT+P")),
             ],
         ),
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -620,6 +620,16 @@ impl Dialog {
                 d
             }
             "solverResults" => Dialog::custom("solverResults", "Solver Results", params.clone()),
+            "customizeKeyboard" => Dialog::custom(
+                "customizeKeyboard",
+                "Customize Keyboard",
+                json!({
+                    "category": "Home",
+                    "command": "home.bold",
+                    "current": "",
+                    "pressed": "",
+                }),
+            ),
             "subtotal" => Dialog::form(
                 "subtotal",
                 "Subtotal",
@@ -1262,7 +1272,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
     let width = match d.name.as_str() {
         "formatCells" => 560.0,
         "about" => 660.0,
-        "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" => 520.0,
+        "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "macros" | "customizeKeyboard" => 520.0,
         "recommendedCharts" => 600.0,
         "analysisToolPak" => 420.0,
         "solver" => 460.0,
@@ -1307,6 +1317,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
                 "solver" => solver(ui, &mut d, &mut confirm, &mut open),
                 "solverResults" => solver_results(app, ui, &mut d, &mut open),
                 "spelling" => spelling(app, ui, &mut d, &mut open),
+                "customizeKeyboard" => customize_keyboard(app, ui, &mut d, &mut open),
                 "agents" => {
                     ui.label("GridCraft is fully drivable by agents. Every menu item, button and gesture is a command:");
                     ui.add_space(4.0);
@@ -3308,6 +3319,170 @@ fn spelling(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bo
     });
 }
 
+/// Dialog allowing users to customize keyboard shortcuts for ribbon and menu commands.
+fn customize_keyboard(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, open: &mut bool) {
+    let categories = ["Home", "Insert", "Page Layout", "Formulas", "Data", "Review", "View", "Automate", "All Commands"];
+    let cat = d.values.get("category").and_then(Json::as_str).unwrap_or("Home").to_string();
+    let mut pressed = d.values.get("pressed").and_then(Json::as_str).unwrap_or("").to_string();
+
+    ui.horizontal_top(|ui| {
+        // Left Column: Categories
+        ui.vertical(|ui| {
+            ui.label(egui::RichText::new("Categories:").strong());
+            egui::ScrollArea::vertical().id_salt("cat_scroll").max_height(160.0).min_scrolled_width(140.0).show(ui, |ui| {
+                for &c in &categories {
+                    let selected = cat == c;
+                    if ui.selectable_label(selected, c).clicked() {
+                        d.values.insert("category".into(), json!(c));
+                        d.values.insert("command".into(), json!(""));
+                    }
+                }
+            });
+        });
+
+        ui.add_space(8.0);
+
+        // Refresh category and current_cmd after category interaction
+        let cat = d.values.get("category").and_then(Json::as_str).unwrap_or("Home").to_string();
+        let current_cmd = d.values.get("command").and_then(Json::as_str).unwrap_or("").to_string();
+
+        // Right Column: Commands
+        ui.vertical(|ui| {
+            ui.label(egui::RichText::new("Commands:").strong());
+            let all_specs = gridcraft_engine::command_specs();
+            let filtered: Vec<&gridcraft_engine::cmd::CommandSpec> =
+                all_specs.iter().filter(|s| if cat == "All Commands" { true } else { s.menu.first().is_some_and(|m| *m == cat) }).collect();
+
+            let active_cmd = if current_cmd.is_empty() { filtered.first().map(|s| s.id).unwrap_or("") } else { &current_cmd };
+
+            egui::ScrollArea::vertical().id_salt("cmd_scroll").max_height(160.0).min_scrolled_width(220.0).show(ui, |ui| {
+                for s in &filtered {
+                    let selected = active_cmd == s.id;
+                    if ui.selectable_label(selected, s.label).clicked() {
+                        d.values.insert("command".into(), json!(s.id));
+                    }
+                }
+            });
+
+            if current_cmd.is_empty() && !active_cmd.is_empty() {
+                d.values.insert("command".into(), json!(active_cmd));
+            }
+        });
+    });
+
+    ui.separator();
+
+    let sel_id = d.values.get("command").and_then(Json::as_str).unwrap_or("").to_string();
+    let sel_spec = gridcraft_engine::find_command(&sel_id);
+
+    // Current shortcuts for this command
+    let mut current_keys: Vec<String> = Vec::new();
+    if let Some(spec) = sel_spec
+        && let Some(sc) = spec.shortcut
+        && !app.ui.shortcuts.get(sc).is_some_and(|t| t != &sel_id)
+    {
+        current_keys.push(sc.to_string());
+    }
+    for (k, target) in &app.ui.shortcuts {
+        if target == &sel_id && !current_keys.contains(k) {
+            current_keys.push(k.clone());
+        }
+    }
+
+    ui.horizontal(|ui| {
+        ui.vertical(|ui| {
+            ui.label("Current keys:");
+            if current_keys.is_empty() {
+                ui.weak("[None]");
+            } else {
+                for k in &current_keys {
+                    ui.monospace(k);
+                }
+            }
+        });
+
+        ui.add_space(20.0);
+
+        ui.vertical(|ui| {
+            let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
+            ui.label("Press new keyboard shortcut:");
+            let r = ui.add(egui::TextEdit::singleline(&mut pressed).hint_text("e.g. Control+Option+E, Ctrl+Shift+F"));
+            if r.changed() {
+                d.values.insert("pressed".into(), json!(pressed.clone()));
+            }
+
+            if r.has_focus() {
+                ui.input(|i| {
+                    for e in &i.events {
+                        if let egui::Event::Key { key, pressed: true, modifiers, .. } = e
+                            && let Some(combo) = crate::keymap::canonical_combo(*key, *modifiers, mac)
+                        {
+                            d.values.insert("pressed".into(), json!(combo));
+                        }
+                    }
+                });
+            }
+
+            let p_val = d.values.get("pressed").and_then(Json::as_str).unwrap_or("");
+            let norm_p_val = crate::keymap::normalize_for_platform(p_val, mac);
+            ui.horizontal(|ui| {
+                ui.label("Currently assigned to:");
+                if p_val.is_empty() {
+                    ui.weak("[unassigned]");
+                } else if let Some(assigned_to) = norm_p_val.as_ref().and_then(|norm| app.ui.shortcuts.get(norm)) {
+                    let lbl = gridcraft_engine::find_command(assigned_to).map(|s| s.label).unwrap_or(assigned_to);
+                    ui.label(egui::RichText::new(lbl).strong());
+                } else {
+                    let mut found = None;
+                    for spec in gridcraft_engine::command_specs() {
+                        if let Some(sc) = spec.shortcut
+                            && (sc.eq_ignore_ascii_case(p_val) || norm_p_val.as_deref().is_some_and(|n| sc.eq_ignore_ascii_case(n)))
+                            && !app.ui.shortcuts.contains_key(sc)
+                        {
+                            found = Some(spec.label);
+                            break;
+                        }
+                    }
+                    if let Some(lbl) = found {
+                        ui.label(egui::RichText::new(lbl).strong());
+                    } else {
+                        ui.weak("[unassigned]");
+                    }
+                }
+            });
+        });
+    });
+
+    ui.add_space(8.0);
+    ui.horizontal(|ui| {
+        let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
+        let p_val = d.values.get("pressed").and_then(Json::as_str).unwrap_or("").to_string();
+        let can_assign = !sel_id.is_empty() && !p_val.is_empty();
+        if ui.add_enabled(can_assign, egui::Button::new("Assign")).clicked()
+            && let Some(norm) = crate::keymap::normalize_for_platform(&p_val, mac)
+        {
+            app.ui.shortcuts.insert(norm, sel_id.clone());
+            d.values.insert("pressed".into(), json!(""));
+        }
+        let can_remove = current_keys.iter().any(|k| app.ui.shortcuts.get(k) == Some(&sel_id));
+        if ui.add_enabled(can_remove, egui::Button::new("Remove")).clicked() {
+            for k in &current_keys {
+                if app.ui.shortcuts.get(k) == Some(&sel_id) {
+                    app.ui.shortcuts.remove(k);
+                }
+            }
+        }
+        if ui.add_enabled(!app.ui.shortcuts.is_empty(), egui::Button::new("Reset All…")).clicked() {
+            app.ui.shortcuts.clear();
+        }
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            if ui.button("OK").clicked() {
+                *open = false;
+            }
+        });
+    });
+}
+
 #[cfg(test)]
 mod data_validation_tests {
     use super::*;
@@ -3409,4 +3584,15 @@ mod tests {
         assert_eq!(d.values["type"], "list");
         assert!(d.values.get("formula1").is_none());
     }
+
+    #[test]
+    fn customize_keyboard_opens_with_default_category_and_command() {
+        let mut session = gridcraft_engine::Session::default();
+        session.new_workbook();
+        let mut app = SheetApp::new(session, Default::default());
+        let d = Dialog::open(&mut app, "customizeKeyboard", json!({})).unwrap();
+        assert_eq!(d.title, "Customize Keyboard");
+        assert_eq!(d.values["category"], "Home");
+        assert_eq!(d.values["command"], "home.bold");
+    }
 }
diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -3037,10 +3037,38 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
     for ev in events {
         match ev {
             egui::Event::Copy => {
-                app.copy_to_clipboard(ctx, "edit.copy");
+                let target = if ctx.input(|i| i.key_down(egui::Key::Insert)) {
+                    app.ui.shortcuts.get("Ctrl+Insert").cloned()
+                } else if ctx.input(|i| i.key_down(egui::Key::C)) || !ctx.input(|i| i.modifiers.ctrl || i.modifiers.command) {
+                    let mac = ctx.os() == egui::os::OperatingSystem::Mac;
+                    let copy_combo = if mac { "Cmd+C" } else { "Ctrl+C" };
+                    app.ui.shortcuts.get(copy_combo).cloned()
+                } else {
+                    None
+                };
+                if let Some(target) = target {
+                    crate::keymap::dispatch_target(app, ctx, &target);
+                    shortcut_ran = true;
+                } else {
+                    app.copy_to_clipboard(ctx, "edit.copy");
+                }
             }
             egui::Event::Cut => {
-                app.copy_to_clipboard(ctx, "edit.cut");
+                let target = if ctx.input(|i| i.key_down(egui::Key::Delete)) {
+                    app.ui.shortcuts.get("Shift+Delete").cloned()
+                } else if ctx.input(|i| i.key_down(egui::Key::X)) || !ctx.input(|i| i.modifiers.ctrl || i.modifiers.command) {
+                    let mac = ctx.os() == egui::os::OperatingSystem::Mac;
+                    let cut_combo = if mac { "Cmd+X" } else { "Ctrl+X" };
+                    app.ui.shortcuts.get(cut_combo).cloned()
+                } else {
+                    None
+                };
+                if let Some(target) = target {
+                    crate::keymap::dispatch_target(app, ctx, &target);
+                    shortcut_ran = true;
+                } else {
+                    app.copy_to_clipboard(ctx, "edit.cut");
+                }
             }
             egui::Event::Paste(text) => app.paste_external(text),
             egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs
--- a/crates/ui-egui/src/keymap.rs
+++ b/crates/ui-egui/src/keymap.rs
@@ -178,12 +178,253 @@ pub(crate) fn altgr_text(ctx: &egui::Context, m: Modifiers, mac: bool) -> bool {
     !mac && m.alt && m.ctrl && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))))
 }
 
+/// Canonical name for an egui Key.
+pub fn key_name(k: Key) -> Option<&'static str> {
+    Some(match k {
+        Key::ArrowLeft => "Left",
+        Key::ArrowRight => "Right",
+        Key::ArrowUp => "Up",
+        Key::ArrowDown => "Down",
+        Key::Home => "Home",
+        Key::End => "End",
+        Key::PageUp => "PageUp",
+        Key::PageDown => "PageDown",
+        Key::Enter => "Enter",
+        Key::Tab => "Tab",
+        Key::Backspace => "Backspace",
+        Key::Delete => "Delete",
+        Key::Escape => "Escape",
+        Key::Space => "Space",
+        Key::Insert => "Insert",
+        Key::Equals | Key::Plus => "=",
+        Key::Minus => "-",
+        Key::Period => ".",
+        Key::Comma => ",",
+        Key::OpenBracket => "[",
+        Key::CloseBracket => "]",
+        Key::Semicolon => ";",
+        Key::Quote => "'",
+        Key::Backtick => "`",
+        Key::Num0 => "0",
+        Key::Num1 => "1",
+        Key::Num2 => "2",
+        Key::Num3 => "3",
+        Key::Num4 => "4",
+        Key::Num5 => "5",
+        Key::Num6 => "6",
+        Key::Num7 => "7",
+        Key::Num8 => "8",
+        Key::Num9 => "9",
+        Key::F1 => "F1",
+        Key::F2 => "F2",
+        Key::F3 => "F3",
+        Key::F4 => "F4",
+        Key::F5 => "F5",
+        Key::F6 => "F6",
+        Key::F7 => "F7",
+        Key::F8 => "F8",
+        Key::F9 => "F9",
+        Key::F10 => "F10",
+        Key::F11 => "F11",
+        Key::F12 => "F12",
+        Key::A => "A",
+        Key::B => "B",
+        Key::C => "C",
+        Key::D => "D",
+        Key::E => "E",
+        Key::F => "F",
+        Key::G => "G",
+        Key::H => "H",
+        Key::I => "I",
+        Key::J => "J",
+        Key::K => "K",
+        Key::L => "L",
+        Key::M => "M",
+        Key::N => "N",
+        Key::O => "O",
+        Key::P => "P",
+        Key::Q => "Q",
+        Key::R => "R",
+        Key::S => "S",
+        Key::T => "T",
+        Key::U => "U",
+        Key::V => "V",
+        Key::W => "W",
+        Key::X => "X",
+        Key::Y => "Y",
+        Key::Z => "Z",
+        _ => return None,
+    })
+}
+
+/// Canonical representation of a key combination, e.g. "Ctrl+Shift+E" or "Cmd+Option+E".
+pub fn canonical_combo(key: Key, m: Modifiers, mac: bool) -> Option<String> {
+    let name = key_name(key)?;
+    let is_function_key =
+        matches!(key, Key::F1 | Key::F2 | Key::F3 | Key::F4 | Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 | Key::F11 | Key::F12);
+    if !m.ctrl && !m.command && !m.alt && !m.shift && !is_function_key {
+        return None;
+    }
+    let mut s = String::new();
+    if mac {
+        if m.command || m.mac_cmd {
+            s.push_str("Cmd+");
+        }
+        if m.ctrl {
+            s.push_str("Ctrl+");
+        }
+        if m.alt {
+            s.push_str("Option+");
+        }
+        if m.shift {
+            s.push_str("Shift+");
+        }
+    } else {
+        if m.ctrl || m.command {
+            s.push_str("Ctrl+");
+        }
+        if m.alt {
+            s.push_str("Alt+");
+        }
+        if m.shift {
+            s.push_str("Shift+");
+        }
+    }
+    s.push_str(name);
+    Some(s)
+}
+
+fn canonical_key_name(s: &str) -> Option<String> {
+    if s.len() == 1 {
+        let c = s.chars().next()?.to_ascii_uppercase();
+        if c.is_ascii_alphanumeric() || matches!(c, '=' | '-' | '.' | ',' | '[' | ']' | ';' | '\'' | '`') {
+            return Some(c.to_string());
+        }
+        return None;
+    }
+    if let Some(f) = s.strip_prefix(['f', 'F'])
+        && let Ok(n) = f.parse::<u8>()
+        && (1..=12).contains(&n)
+    {
+        return Some(format!("F{n}"));
+    }
+    match s.to_ascii_lowercase().as_str() {
+        "left" | "arrowleft" => Some("Left".into()),
+        "right" | "arrowright" => Some("Right".into()),
+        "up" | "arrowup" => Some("Up".into()),
+        "down" | "arrowdown" => Some("Down".into()),
+        "home" => Some("Home".into()),
+        "end" => Some("End".into()),
+        "pageup" | "pgup" => Some("PageUp".into()),
+        "pagedown" | "pgdn" => Some("PageDown".into()),
+        "enter" | "return" => Some("Enter".into()),
+        "tab" => Some("Tab".into()),
+        "backspace" => Some("Backspace".into()),
+        "delete" | "del" => Some("Delete".into()),
+        "escape" | "esc" => Some("Escape".into()),
+        "space" => Some("Space".into()),
+        "insert" | "ins" => Some("Insert".into()),
+        _ => None,
+    }
+}
+
+/// Normalizes any user-entered shortcut string for the specified platform.
+pub fn normalize_for_platform(s: &str, mac: bool) -> Option<String> {
+    let s = s.trim();
+    if s.is_empty() {
+        return None;
+    }
+    let parts: Vec<&str> = s.split('+').map(str::trim).collect();
+    if parts.is_empty() {
+        return None;
+    }
+    let mut ctrl = false;
+    let mut alt = false;
+    let mut shift = false;
+    let mut cmd = false;
+    let key_part = parts.last()?;
+    for p in &parts[..parts.len() - 1] {
+        match p.to_ascii_lowercase().as_str() {
+            "ctrl" | "control" => ctrl = true,
+            "alt" | "option" | "opt" => alt = true,
+            "shift" => shift = true,
+            "cmd" | "command" | "mod" => cmd = true,
+            _ => return None,
+        }
+    }
+    let key_name = canonical_key_name(key_part)?;
+    let is_function_key = key_name.starts_with('F') && key_name[1..].parse::<u8>().is_ok();
+    if !ctrl && !alt && !shift && !cmd && !is_function_key {
+        return None;
+    }
+    let mut out = String::new();
+    if mac {
+        if cmd {
+            out.push_str("Cmd+");
+        }
+        if ctrl {
+            out.push_str("Ctrl+");
+        }
+        if alt {
+            out.push_str("Option+");
+        }
+        if shift {
+            out.push_str("Shift+");
+        }
+    } else {
+        if cmd || ctrl {
+            out.push_str("Ctrl+");
+        }
+        if alt {
+            out.push_str("Alt+");
+        }
+        if shift {
+            out.push_str("Shift+");
+        }
+    }
+    out.push_str(&key_name);
+    Some(out)
+}
+
+/// Normalizes any user-entered shortcut string (case-insensitively, e.g. "ctrl+shift+f" -> "Ctrl+Shift+F").
+pub fn normalize(s: &str) -> Option<String> {
+    normalize_for_platform(s, cfg!(target_os = "macos"))
+}
+
+pub(crate) fn dispatch_target(app: &mut SheetApp, ctx: &egui::Context, target: &str) {
+    if let Some(dialog_name) = target.strip_prefix("dialog:") {
+        app.open_dialog(dialog_name, json!({}));
+    } else if matches!(target, "edit.copy" | "edit.cut") {
+        app.copy_to_clipboard(ctx, target);
+    } else if target == "view.zoom" {
+        app.open_dialog("zoom", json!({}));
+    } else {
+        let params = match target {
+            "home.borders" => {
+                let preset = if app.grid.last_border.is_empty() { "all" } else { &app.grid.last_border };
+                json!({"preset": preset})
+            }
+            _ => json!({}),
+        };
+        app.run_or_alert(target, params);
+    }
+    app.grid.ensure_visible = true;
+}
+
 /// Handles a key press in the grid. Returns whether it was a shortcut.
 pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physical: Option<Key>, m: Modifiers, geo: &Geo, sh: &Sheet) -> bool {
     let mac = ctx.os() == egui::os::OperatingSystem::Mac;
     if altgr_text(ctx, m, mac) {
         return false;
     }
+    // Check custom shortcuts before built-in shortcuts
+    if let Some(combo) = canonical_combo(key, m, mac)
+        && let Some(target) =
+            app.ui.shortcuts.get(&combo).or_else(|| normalize_for_platform(&combo, mac).as_ref().and_then(|nc| app.ui.shortcuts.get(nc))).cloned()
+    {
+        dispatch_target(app, ctx, &target);
+        return true;
+    }
     let Some(shortcut) = lookup(key, physical, m, mac) else { return false };
     let (active, anchor) = app.session.active().map(|d| (d.selection.active, d.selection.anchor)).unwrap_or_default();
     match shortcut {
@@ -267,3 +508,35 @@ fn extend(app: &mut SheetApp, anchor: CellRef, active: CellRef, to: CellRef) {
     app.grid.ensure_visible = true;
     app.grid.reveal_moving_end = true;
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn canonical_combo_formats_keys() {
+        let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
+        let ctrl_shift = Modifiers { ctrl: true, shift: true, command: true, ..Modifiers::NONE };
+        assert_eq!(canonical_combo(Key::B, mac_cmd, true), Some("Cmd+B".into()));
+        assert_eq!(canonical_combo(Key::K, ctrl_shift, false), Some("Ctrl+Shift+K".into()));
+        assert_eq!(canonical_combo(Key::F5, Modifiers::NONE, false), Some("F5".into()));
+        assert_eq!(canonical_combo(Key::F, Modifiers::NONE, false), None);
+    }
+
+    #[test]
+    fn normalize_shortcuts() {
+        assert_eq!(normalize_for_platform("ctrl+shift+k", false), Some("Ctrl+Shift+K".into()));
+        assert_eq!(normalize_for_platform("cmd+b", false), Some("Ctrl+B".into()));
+        assert_eq!(normalize_for_platform("cmd+b", true), Some("Cmd+B".into()));
+        assert_eq!(normalize_for_platform("alt+k", true), Some("Option+K".into()));
+        assert_eq!(normalize_for_platform("option+k", false), Some("Alt+K".into()));
+        assert_eq!(normalize_for_platform("mod+shift+m", true), Some("Cmd+Shift+M".into()));
+        assert_eq!(normalize_for_platform("ctrl + alt + f1", false), Some("Ctrl+Alt+F1".into()));
+        assert_eq!(normalize_for_platform("ctrl+pageup", false), Some("Ctrl+PageUp".into()));
+        assert_eq!(normalize_for_platform("ctrl+pagedown", false), Some("Ctrl+PageDown".into()));
+        assert_eq!(normalize_for_platform("ctrl+whatever", false), None);
+        assert_eq!(normalize_for_platform("ctrl+f13", false), None);
+        assert_eq!(normalize_for_platform("f", false), None);
+        assert_eq!(normalize_for_platform("f2", false), Some("F2".into()));
+    }
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -81,6 +81,9 @@ pub struct UiState {
     /// [`INTERFACE_SCALE_MIN`]..=[`INTERFACE_SCALE_MAX`]; a malformed saved value reads as 100.
     #[serde(deserialize_with = "lenient_interface_scale")]
     pub interface_scale: u16,
+    /// Custom keyboard shortcut bindings (canonical shortcut -> command id).
+    #[serde(default)]
+    pub shortcuts: std::collections::HashMap<String, String>,
 }
 
 pub const INTERFACE_SCALE_MIN: u16 = 75;
@@ -113,6 +116,7 @@ impl Default for UiState {
             keytip_invoke: None,
             language: i18n::Language::En,
             interface_scale: 100,
+            shortcuts: std::collections::HashMap::new(),
         }
     }
 }
diff --git a/crates/ui-egui/tests/it/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs
--- a/crates/ui-egui/tests/it/keyboard_shortcuts.rs
+++ b/crates/ui-egui/tests/it/keyboard_shortcuts.rs
@@ -306,3 +306,48 @@ fn ctrl_alt_letter_that_typed_text_is_not_select_all() {
     assert_eq!(h.state().session.active().unwrap().selection.current().a1(), "B2");
     assert_eq!(h.state().editor.as_ref().unwrap().text, "\u{e4}");
 }
+
+#[test]
+fn custom_shortcut_triggers_command_and_dialog() {
+    let mut h = harness(book("A1:B2"));
+    let mac = h.ctx.os() == egui::os::OperatingSystem::Mac;
+    let combo = if mac { "Cmd+Shift+K" } else { "Ctrl+Shift+K" };
+    let mods = if mac { Modifiers { mac_cmd: true, command: true, shift: true, ..Modifiers::NONE } } else { CTRL_SHIFT };
+    h.state_mut().ui.shortcuts.insert(combo.into(), "dialog:customizeKeyboard".into());
+    key(&mut h, Key::K, mods);
+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("customizeKeyboard"));
+    h.state_mut().dialog = None;
+
+    // Configurable commands like home.borders receive default parameters
+    let border_combo = if mac { "Cmd+Shift+B" } else { "Ctrl+Shift+B" };
+    h.state_mut().ui.shortcuts.insert(border_combo.into(), "home.borders".into());
+    key(&mut h, Key::B, mods);
+    assert!(style(&h, "B2").borders.bottom.style != BorderStyle::None);
+
+    // view.zoom opens the zoom dialog without error
+    let zoom_combo = if mac { "Cmd+Shift+Z" } else { "Ctrl+Shift+Z" };
+    h.state_mut().ui.shortcuts.insert(zoom_combo.into(), "view.zoom".into());
+    key(&mut h, Key::Z, mods);
+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("zoom"));
+    h.state_mut().dialog = None;
+
+    // Native copy event checks custom shortcuts before clipboard fallback
+    let copy_combo = if mac { "Cmd+C" } else { "Ctrl+C" };
+    h.state_mut().ui.shortcuts.insert(copy_combo.into(), "dialog:customizeKeyboard".into());
+    h.input_mut().events.push(Event::Copy);
+    h.run_steps(1);
+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("customizeKeyboard"));
+    h.state_mut().dialog = None;
+
+    // Alternate clipboard keys (Insert held) stay on clipboard path, not triggering Cmd/Ctrl+C target
+    h.input_mut().events.push(Event::Key {
+        key: Key::Insert,
+        physical_key: Some(Key::Insert),
+        pressed: true,
+        repeat: false,
+        modifiers: Modifiers::CTRL,
+    });
+    h.input_mut().events.push(Event::Copy);
+    h.run_steps(1);
+    assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), None);
+}
