diff --git a/book/src/configuration.md b/book/src/configuration.md
index 8857af82..c55426c0 100644
--- a/book/src/configuration.md
+++ b/book/src/configuration.md
@@ -68,6 +68,7 @@ Its settings will be merged with the configuration directory `config.toml` and t
 | `insert-final-newline` | Whether to automatically insert a trailing line-ending on write if missing | `true` |
 | `popup-border` | Draw border around `popup`, `menu`, `all`, or `none` | `none` |
 | `indent-heuristic` | How the indentation for a newly inserted line is computed: `simple` just copies the indentation level from the previous line, `tree-sitter` computes the indentation based on the syntax tree and `hybrid` combines both approaches. If the chosen heuristic is not available, a different one will be used as a fallback (the fallback order being `hybrid` -> `tree-sitter` -> `simple`). | `hybrid`
+| `jump-label-alphabet` | The characters that are used to generate two character jump labels. Characters at the start of the alphabet are used first. | "abcdefghijklmnopqrstuvwxyz"
 
 ### `[editor.statusline]` Section
 
diff --git a/book/src/keymap.md b/book/src/keymap.md
index 60ada4a2..3a5ccca5 100644
--- a/book/src/keymap.md
+++ b/book/src/keymap.md
@@ -224,6 +224,7 @@ Jumps to various locations.
 | `.`   | Go to last modification in current file          | `goto_last_modification`   |
 | `j`   | Move down textual (instead of visual) line       | `move_line_down`           |
 | `k`   | Move up textual (instead of visual) line         | `move_line_up`             |
+| `w`   | Show labels at each word and select the word that belongs to the entered labels | `goto_word` |
 
 #### Match mode
 
diff --git a/book/src/themes.md b/book/src/themes.md
index 04d6a69b..29a8c4ba 100644
--- a/book/src/themes.md
+++ b/book/src/themes.md
@@ -314,6 +314,7 @@ These scopes are used for theming the editor interface:
 | `ui.virtual.inlay-hint.parameter` | Style for inlay hints of kind `parameter` (LSPs are not required to set a kind)                |
 | `ui.virtual.inlay-hint.type`      | Style for inlay hints of kind `type` (LSPs are not required to set a kind)                     |
 | `ui.virtual.wrap`                 | Soft-wrap indicator (see the [`editor.soft-wrap` config][editor-section])                      |
+| `ui.virtual.jump-label`           | Style for virtual jump labels                                                                  |
 | `ui.menu`                         | Code and command completion menus                                                              |
 | `ui.menu.selected`                | Selected autocomplete item                                                                     |
 | `ui.menu.scroll`                  | `fg` sets thumb color, `bg` sets track color of scrollbar                                      |
diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs
index bd252deb..579499de 100644
--- a/helix-core/src/selection.rs
+++ b/helix-core/src/selection.rs
@@ -705,6 +705,15 @@ impl IntoIterator for Selection {
     }
 }
 
+impl From<Range> for Selection {
+    fn from(range: Range) -> Self {
+        Self {
+            ranges: smallvec![range],
+            primary_index: 0,
+        }
+    }
+}
+
 // TODO: checkSelection -> check if valid for doc length && sorted
 
 pub fn keep_or_remove_matches(
diff --git a/helix-term/src/commands.rs b/helix-term/src/commands.rs
index 6de05651..d545480b 100644
--- a/helix-term/src/commands.rs
+++ b/helix-term/src/commands.rs
@@ -10,9 +10,12 @@ use tui::widgets::Row;
 pub use typed::*;
 
 use helix_core::{
-    char_idx_at_visual_offset, comment,
+    char_idx_at_visual_offset,
+    chars::char_is_word,
+    comment,
     doc_formatter::TextFormat,
-    encoding, find_workspace, graphemes,
+    encoding, find_workspace,
+    graphemes::{self, next_grapheme_boundary, RevRopeGraphemes},
     history::UndoKind,
     increment, indent,
     indent::IndentStyle,
@@ -24,7 +27,7 @@ use helix_core::{
     search::{self, CharMatcher},
     selection, shellwords, surround,
     syntax::{BlockCommentToken, LanguageServerFeature},
-    text_annotations::TextAnnotations,
+    text_annotations::{Overlay, TextAnnotations},
     textobject,
     unicode::width::UnicodeWidthChar,
     visual_offset_from_block, Deletion, LineEnding, Position, Range, Rope, RopeGraphemes,
@@ -502,6 +505,8 @@ impl MappableCommand {
         record_macro, "Record macro",
         replay_macro, "Replay macro",
         command_palette, "Open command palette",
+        goto_word, "Jump to a two-character label",
+        extend_to_word, "Extend to a two-character label",
     );
 }
 
@@ -5814,3 +5819,182 @@ fn replay_macro(cx: &mut Context) {
         cx.editor.macro_replaying.pop();
     }));
 }
+
+fn goto_word(cx: &mut Context) {
+    jump_to_word(cx, Movement::Move)
+}
+
+fn extend_to_word(cx: &mut Context) {
+    jump_to_word(cx, Movement::Extend)
+}
+
+fn jump_to_label(cx: &mut Context, labels: Vec<Range>, behaviour: Movement) {
+    let doc = doc!(cx.editor);
+    let alphabet = &cx.editor.config().jump_label_alphabet;
+    if labels.is_empty() {
+        return;
+    }
+    let alphabet_char = |i| {
+        let mut res = Tendril::new();
+        res.push(alphabet[i]);
+        res
+    };
+
+    // Add label for each jump candidate to the View as virtual text.
+    let text = doc.text().slice(..);
+    let mut overlays: Vec<_> = labels
+        .iter()
+        .enumerate()
+        .flat_map(|(i, range)| {
+            [
+                Overlay::new(range.from(), alphabet_char(i / alphabet.len())),
+                Overlay::new(
+                    graphemes::next_grapheme_boundary(text, range.from()),
+                    alphabet_char(i % alphabet.len()),
+                ),
+            ]
+        })
+        .collect();
+    overlays.sort_unstable_by_key(|overlay| overlay.char_idx);
+    let (view, doc) = current!(cx.editor);
+    doc.set_jump_labels(view.id, overlays);
+
+    // Accept two characters matching a visible label. Jump to the candidate
+    // for that label if it exists.
+    let primary_selection = doc.selection(view.id).primary();
+    let view = view.id;
+    let doc = doc.id();
+    cx.on_next_key(move |cx, event| {
+        let alphabet = &cx.editor.config().jump_label_alphabet;
+        let Some(i ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else {
+            doc_mut!(cx.editor, &doc).remove_jump_labels(view);
+            return;
+        };
+        let outer = i * alphabet.len();
+        // Bail if the given character cannot be a jump label.
+        if outer > labels.len() {
+            doc_mut!(cx.editor, &doc).remove_jump_labels(view);
+            return;
+        }
+        cx.on_next_key(move |cx, event| {
+            doc_mut!(cx.editor, &doc).remove_jump_labels(view);
+            let alphabet = &cx.editor.config().jump_label_alphabet;
+            let Some(inner ) = event.char().and_then(|ch| alphabet.iter().position(|&it| it == ch)) else {
+                return;
+            };
+            if let Some(mut range) = labels.get(outer + inner).copied() {
+                range = if behaviour == Movement::Extend {
+                    let anchor = if range.anchor < range.head {
+                        let from = primary_selection.from();
+                        if range.anchor < from {
+                            range.anchor
+                        } else {
+                            from
+                        }
+                    } else {
+                        let to = primary_selection.to();
+                        if range.anchor > to {
+                            range.anchor
+                        } else {
+                            to
+                        }
+                    };
+                     Range::new(anchor, range.head)
+                }else{
+                    range.with_direction(Direction::Forward)
+                };
+                doc_mut!(cx.editor, &doc).set_selection(view, range.into());
+            }
+        });
+    });
+}
+
+fn jump_to_word(cx: &mut Context, behaviour: Movement) {
+    // Calculate the jump candidates: ranges for any visible words with two or
+    // more characters.
+    let alphabet = &cx.editor.config().jump_label_alphabet;
+    let jump_label_limit = alphabet.len() * alphabet.len();
+    let mut words = Vec::with_capacity(jump_label_limit);
+    let (view, doc) = current_ref!(cx.editor);
+    let text = doc.text().slice(..);
+
+    // This is not necessarily exact if there is virtual text like soft wrap.
+    // It's ok though because the extra jump labels will not be rendered.
+    let start = text.line_to_char(text.char_to_line(view.offset.anchor));
+    let end = text.line_to_char(view.estimate_last_doc_line(doc) + 1);
+
+    let primary_selection = doc.selection(view.id).primary();
+    let cursor = primary_selection.cursor(text);
+    let mut cursor_fwd = Range::point(cursor);
+    let mut cursor_rev = Range::point(cursor);
+    if text.get_char(cursor).is_some_and(|c| !c.is_whitespace()) {
+        let cursor_word_end = movement::move_next_word_end(text, cursor_fwd, 1);
+        //  single grapheme words need a specical case
+        if cursor_word_end.anchor == cursor {
+            cursor_fwd = cursor_word_end;
+        }
+        let cursor_word_start = movement::move_prev_word_start(text, cursor_rev, 1);
+        if cursor_word_start.anchor == next_grapheme_boundary(text, cursor) {
+            cursor_rev = cursor_word_start;
+        }
+    }
+    'outer: loop {
+        let mut changed = false;
+        while cursor_fwd.head < end {
+            cursor_fwd = movement::move_next_word_end(text, cursor_fwd, 1);
+            // The cursor is on a word that is atleast two graphemes long and
+            // madeup of word characters. The latter condition is needed because
+            // move_next_word_end simply treats a sequence of characters from
+            // the same char class as a word so `=<` would also count as a word.
+            let add_label = RevRopeGraphemes::new(text.slice(..cursor_fwd.head))
+                .take(2)
+                .take_while(|g| g.chars().all(char_is_word))
+                .count()
+                == 2;
+            if !add_label {
+                continue;
+            }
+            changed = true;
+            // skip any leading whitespace
+            cursor_fwd.anchor += text
+                .chars_at(cursor_fwd.anchor)
+                .take_while(|&c| !char_is_word(c))
+                .count();
+            words.push(cursor_fwd);
+            if words.len() == jump_label_limit {
+                break 'outer;
+            }
+            break;
+        }
+        while cursor_rev.head > start {
+            cursor_rev = movement::move_prev_word_start(text, cursor_rev, 1);
+            // The cursor is on a word that is atleast two graphemes long and
+            // madeup of word characters. The latter condition is needed because
+            // move_prev_word_start simply treats a sequence of characters from
+            // the same char class as a word so `=<` would also count as a word.
+            let add_label = RopeGraphemes::new(text.slice(cursor_rev.head..))
+                .take(2)
+                .take_while(|g| g.chars().all(char_is_word))
+                .count()
+                == 2;
+            if !add_label {
+                continue;
+            }
+            changed = true;
+            cursor_rev.anchor -= text
+                .chars_at(cursor_rev.anchor)
+                .reversed()
+                .take_while(|&c| !char_is_word(c))
+                .count();
+            words.push(cursor_rev);
+            if words.len() == jump_label_limit {
+                break 'outer;
+            }
+            break;
+        }
+        if !changed {
+            break;
+        }
+    }
+    jump_to_label(cx, words, behaviour)
+}
diff --git a/helix-term/src/keymap/default.rs b/helix-term/src/keymap/default.rs
index bab662b0..ca5a21d2 100644
--- a/helix-term/src/keymap/default.rs
+++ b/helix-term/src/keymap/default.rs
@@ -58,6 +58,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
             "k" => move_line_up,
             "j" => move_line_down,
             "." => goto_last_modification,
+            "w" => goto_word,
         },
         ":" => command_mode,
 
@@ -360,6 +361,7 @@ pub fn default() -> HashMap<Mode, KeyTrie> {
         "g" => { "Goto"
             "k" => extend_line_up,
             "j" => extend_line_down,
+            "w" => extend_to_word,
         },
     }));
     let insert = keymap!({ "Insert mode"
diff --git a/helix-view/src/document.rs b/helix-view/src/document.rs
index 090e4dd5..da68a67f 100644
--- a/helix-view/src/document.rs
+++ b/helix-view/src/document.rs
@@ -8,7 +8,7 @@ use helix_core::chars::char_is_word;
 use helix_core::doc_formatter::TextFormat;
 use helix_core::encoding::Encoding;
 use helix_core::syntax::{Highlight, LanguageServerFeature};
-use helix_core::text_annotations::InlineAnnotation;
+use helix_core::text_annotations::{InlineAnnotation, Overlay};
 use helix_lsp::util::lsp_pos_to_pos;
 use helix_vcs::{DiffHandle, DiffProviderRegistry};
 
@@ -125,6 +125,7 @@ pub struct Document {
     ///
     /// To know if they're up-to-date, check the `id` field in `DocumentInlayHints`.
     pub(crate) inlay_hints: HashMap<ViewId, DocumentInlayHints>,
+    pub(crate) jump_labels: HashMap<ViewId, Vec<Overlay>>,
     /// Set to `true` when the document is updated, reset to `false` on the next inlay hints
     /// update from the LSP
     pub inlay_hints_oudated: bool,
@@ -665,6 +666,7 @@ impl Document {
             version_control_head: None,
             focused_at: std::time::Instant::now(),
             readonly: false,
+            jump_labels: HashMap::new(),
         }
     }
 
@@ -1138,6 +1140,7 @@ impl Document {
     pub fn remove_view(&mut self, view_id: ViewId) {
         self.selections.remove(&view_id);
         self.inlay_hints.remove(&view_id);
+        self.jump_labels.remove(&view_id);
     }
 
     /// Apply a [`Transaction`] to the [`Document`] to change its text.
@@ -1943,6 +1946,14 @@ impl Document {
         self.inlay_hints.insert(view_id, inlay_hints);
     }
 
+    pub fn set_jump_labels(&mut self, view_id: ViewId, labels: Vec<Overlay>) {
+        self.jump_labels.insert(view_id, labels);
+    }
+
+    pub fn remove_jump_labels(&mut self, view_id: ViewId) {
+        self.jump_labels.remove(&view_id);
+    }
+
     /// Get the inlay hints for this document and `view_id`.
     pub fn inlay_hints(&self, view_id: ViewId) -> Option<&DocumentInlayHints> {
         self.inlay_hints.get(&view_id)
diff --git a/helix-view/src/editor.rs b/helix-view/src/editor.rs
index bad61052..3c530c4e 100644
--- a/helix-view/src/editor.rs
+++ b/helix-view/src/editor.rs
@@ -22,7 +22,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream;
 use std::{
     borrow::Cow,
     cell::Cell,
-    collections::{BTreeMap, HashMap},
+    collections::{BTreeMap, HashMap, HashSet},
     fs,
     io::{self, stdin},
     num::NonZeroUsize,
@@ -212,6 +212,23 @@ impl Default for FilePickerConfig {
     }
 }
 
+fn deserialize_alphabet<'de, D>(deserializer: D) -> Result<Vec<char>, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    use serde::de::Error;
+
+    let str = String::deserialize(deserializer)?;
+    let chars: Vec<_> = str.chars().collect();
+    let unique_chars: HashSet<_> = chars.iter().copied().collect();
+    if unique_chars.len() != chars.len() {
+        return Err(<D::Error as Error>::custom(
+            "jump-label-alphabet must contain unique characters",
+        ));
+    }
+    Ok(chars)
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case", default, deny_unknown_fields)]
 pub struct Config {
@@ -305,6 +322,9 @@ pub struct Config {
     /// Which indent heuristic to use when a new line is inserted
     #[serde(default)]
     pub indent_heuristic: IndentationHeuristic,
+    /// labels characters used in jumpmode
+    #[serde(skip_serializing, deserialize_with = "deserialize_alphabet")]
+    pub jump_label_alphabet: Vec<char>,
 }
 
 #[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Eq, PartialOrd, Ord)]
@@ -870,6 +890,7 @@ impl Default for Config {
             smart_tab: Some(SmartTabConfig::default()),
             popup_border: PopupBorderConfig::None,
             indent_heuristic: IndentationHeuristic::default(),
+            jump_label_alphabet: ('a'..='z').collect(),
         }
     }
 }
diff --git a/helix-view/src/view.rs b/helix-view/src/view.rs
index bbdc74a7..b5dc0615 100644
--- a/helix-view/src/view.rs
+++ b/helix-view/src/view.rs
@@ -415,6 +415,13 @@ impl View {
     ) -> TextAnnotations<'a> {
         let mut text_annotations = TextAnnotations::default();
 
+        if let Some(labels) = doc.jump_labels.get(&self.id) {
+            let style = theme
+                .and_then(|t| t.find_scope_index("ui.virtual.jump-label"))
+                .map(Highlight);
+            text_annotations.add_overlay(labels, style);
+        }
+
         let DocumentInlayHints {
             id: _,
             type_inlay_hints,
diff --git a/runtime/themes/everforest_dark.toml b/runtime/themes/everforest_dark.toml
index 25a49134..a3083d53 100644
--- a/runtime/themes/everforest_dark.toml
+++ b/runtime/themes/everforest_dark.toml
@@ -40,6 +40,7 @@
 "constructor" = "green"
 "module" = "yellow"
 "special" = "blue"
+"ui.virtual.jump-label" = { fg = "#00dfff", modifiers = ["bold"] }
 
 "markup.heading.marker" = "grey1"
 "markup.heading.1" = { fg = "red", modifiers = ["bold"] }
diff --git a/theme.toml b/theme.toml
index 8a5bfd72..a853ce6f 100644
--- a/theme.toml
+++ b/theme.toml
@@ -52,11 +52,11 @@ label = "honey"
 "ui.popup" = { bg = "revolver" }
 "ui.window" = { fg = "bossanova" }
 "ui.help" = { bg = "#7958DC", fg = "#171452" }
-
 "ui.text" = { fg = "lavender" }
 "ui.text.focus" = { fg = "white" }
 "ui.text.inactive" = "sirocco"
 "ui.virtual" = { fg = "comet" }
+"ui.virtual.jump-label" = { fg = "apricot", modifiers = ["bold"] }
 
 "ui.virtual.indent-guide" = { fg = "comet" }