Normal Mode Navigation
Navigation in Normal mode is the core of Vim's speed advantage. Every key is a movement command and they all compose with operators. The goal is to move with precision — never use arrow keys more than once.
Learn navigation in layers: character → word → line → screen → file → pattern-based. Practice each before moving to the next.
Layer 1: Character Navigation
h ← move left (one char)
l → move right (one char)
j ↓ move down (one line)
k ↑ move up (one line)
These were the arrow keys on the original ADM-3A terminal where Vi was created. They keep your hands on the home row.
Add counts for repeated movement:
5l → move right 5 characters
10j → move down 10 lines
Layer 2: Word Navigation
| Key | Motion | Punctuation |
|---|---|---|
| w | Next word start (punctuation-aware) | matters |
| W | Next WORD start (space-delimited only) | ignored |
| b | Previous word start (back) | matters |
| B | Previous WORD start | ignored |
| e | end of current/next word | matters |
| E | End of current/next WORD | ignored |
| ge | End of previous word | matters |
| gE | End of previous WORD | ignored |
Word vs WORD
Vim has two definitions of a "word":
word= letters, digits, and underscores grouped together; punctuation (-,.,/,(, etc.) creates boundaries.WORD= anything separated only by whitespace; punctuation stays attached.
Think of it as:
word = punctuation matters
WORD = whitespace matters
For hello-world/foo.txt (cursor at h), w treats punctuation as boundaries:
hello - world / foo . txt
^ ^ ^ ^ ^ ^ ^
Each token (including each punctuation mark) is its own word, so w steps through all of them.
W, however, sees the whole token as one chunk:
hello-world/foo.txt another-file.txt
^ ^
W (jumps straight to the next chunk)
Same idea with one/two-three four:
w sees: one / two - three four
W sees: one/two-three four
w vs e
w goes to the beginning of the next word; e goes to the end of the current/next word.
hello world vim
^ (start)
^ (w → start of "world")
^ (w → start of "vim")
hello world vim
^ (start)
^ (e → end of "hello")
^ (e → end of "world")
Mental model — w targets word beginnings, e targets word ends:
The quick brown fox
^ ^ ^ ^ ← w targets the left side
^ ^ ^ ^ ← e targets the right side
| Starting position | w | e |
|---|---|---|
| Beginning of word | Next word's start | Current word's end |
| Middle of word | Next word's start | Current word's end |
| End of word | Next word's start | Usually next word's end |
b and ge — moving backward
b→ previous word start (reverse ofw, punctuation-aware).B→ previous WORD start (reverse ofW).ge→ end of previous word (backward equivalent ofe).gE→ end of previous WORD.
one/two-three
^ (b → start of "two")
^ (b → start of "one")
one two three
^ (start: end of "three")
^ (ge → end of "two")
Word motions with operators
The w vs e difference matters most when you combine them with an operator:
hello world
^
dw → "world" (deletes the word AND the trailing space)
de → " world" (deletes the word, keeps the space)
dw → delete toward the next word (eats trailing whitespace)
de → delete through the end of the current word (keeps whitespace)
cw → change toward the next word
ce → change through the end of the current word
yw → yank toward the next word
ye → yank through the end of the current word
When editing, e is often the more precise motion — it operates on the current word without automatically crossing into the next word's space.
Layer 3: Line Navigation
| Key | Action |
|---|---|
| 0 | Go to column 0 (line start) |
| ^ | Go to first non-blank character |
| $ | Go to end of line |
| g_ | Go to last non-blank of line |
| gj / gk | Move by display line (wrapped lines) |
| f{char} | Find next occurrence of char on line |
| F{char} | Find previous occurrence of char on line |
| t{char} | Move to (one before) next char |
| T{char} | Move to (one after) previous char |
| ; | Repeat last f/F/t/T forward |
| , | Repeat last f/F/t/T backward |
Cursor Landing Diagrams
0, ^, $, and g_ jump to a fixed spot on the line:
const result = calculateTotal(items, tax);
^ ^ ^
0 ^ $ / g_
0→ column 0 (the very first character, even if it is a space)^→ first non-blank character$→ last character on the lineg_→ last non-blank character (differs from$only when the line ends in whitespace)
f/t hunt forward for a character; F/T hunt backward. f lands on the character, t stops just before it:
find the file first
^ (start)
^ f → next 'f'
^ t → one before next 'f'
With the cursor on the last f, the backward motions mirror them:
find the file first
^ (start)
^ F → previous 'f'
^ T → one after previous 'f'
After using f/F/t/T, ; repeats the search forward and , repeats it backward:
find the file first
^ (start)
^ f → next 'f'
^ ; → repeat f, lands on the next 'f'
^ , → repeat backward
f and t are extremely powerful
For: const result = calculateTotal(items, tax);
fa → jump to first 'a' (in calculate)
3f, → jump to 3rd comma
tf → move to just before first 'f'
Layer 4: Screen Navigation
| Key | Action |
|---|---|
| H | High — top of screen |
| M | Middle of screen |
| L | Low — bottom of screen |
| Ctrl+d / Alt+d | Scroll down half screen |
| Ctrl+u / Alt+u | Scroll up half screen |
| Ctrl+f / Alt+Shift+d | Scroll forward full screen |
| Ctrl+b / Alt+Shift+u | Scroll back full screen |
| Ctrl+e | Scroll line down (cursor stays) |
| Ctrl+y | Scroll line up (cursor stays) |
| zz | Center screen on cursor |
| zt | Put cursor line at top |
| zb | Put cursor line at bottom |
LazyVim remaps H and L to switch buffers (previous/next). The original screen motions are overridden. Use [b / ]b for buffer switching instead, or add custom keymaps like lu and ld to restore the screen motions (see Recipes & Customization).
Custom Alt Scrolling (This Config)
This setup remaps the scroll motions to Alt keys so your hands can stay closer to the home row and avoid reaching for Ctrl:
| Key | Action |
|---|---|
| Alt+d | Scroll down half screen (same as Ctrl+d) |
| Alt+u | Scroll up half screen (same as Ctrl+u) |
| Alt+Shift+d | Scroll down full screen (same as Ctrl+f) |
| Alt+Shift+u | Scroll up full screen (same as Ctrl+b) |
These are personal keymaps, not vanilla Vim defaults. In Neovim Alt+Shift+d/Alt+Shift+u are equivalent to Alt+D/Alt+U (the Shift folds into the uppercase letter), so they work on any terminal that passes Alt+D/Alt+U through.
zz is incredibly useful — center the screen when you land on an important line.
Layer 5: File Navigation
| Key | Action |
|---|---|
| gg | Go to file start |
| G | Go to file end |
{N}G | Go to line N (e.g., 42G) |
{N}gg | Go to line N (identical to {N}G) |
| :42 | Go to line 42 (command mode) |
| % | Jump to matching bracket/delimiter |
{ / } | Previous / next empty line (paragraph) |
| ( / ) | Previous / next sentence |
| [[ / ]] | Previous / next section/function |
{N}G and {N}gg produce the same resultWhen you supply a count, both {N}G and {N}gg jump to line N — they are identical (verified). The difference is only when you type no count:
G → last line
gg → first line
So 42G and 42gg both go to line 42; G alone goes to the end, and gg alone goes to the top.
% — jump between matching pairs
In Neovim, % is mainly a "find the matching pair" motion. It is much easier if you think of % as:
"I am on one side of a bracket pair. Take me to the other side."
Put the cursor on any opening or closing bracket and press % to toggle between the two ends. Out of the box it matches the pairs in the matchpairs option — (), [], and {}:
| Cursor on | % goes to |
|---|---|
( | matching ) |
[ | matching ] |
{ | matching } |
Basic example: parentheses
print(calculate_total(items))
^ (cursor on "(")
% → jumps to ")"
print(calculate_total(items))
^
Press % again to toggle back to the (.
Square brackets
items = ["apple", "banana", "orange"]
^ (cursor on "[")
% → lands on "]"
This shines when the contents span many lines:
result = some_function(
user,
settings,
database_connection,
options
)
^ ← % from "(" lands on the closing ")"
You immediately know where the function call ends.
Curly braces
function hello() {
console.log("Hello");
return true;
}
^ ← % from "{" lands on the matching "}"
Now you know exactly where the block ends. Press % again and you are back at {.
Nested brackets
% understands nesting — it does not simply find the next closing delimiter:
const result = calculate(items[index], options);
^
% → jumps to the matching ]
function test(data) {
if (data[0].active) {
process(data[0]);
}
}
With the cursor on the { after if, % takes you to the } that closes that if, not the outer function.
% works in both directions
You do not have to start on the opening bracket:
( ↔ )
[ ↔ ]
{ ↔ }
From } press % to reach {; from { press % to reach }.
% as an operator motion
% is a motion, so it composes with operators (operator + motion):
d% → delete from here to the matching delimiter
function hello() {
console.log("hello");
}
^ ← d% deletes the whole block
For bracketed blocks, text objects are often more precise because they express exactly what to include or exclude:
| Command | Meaning |
|---|---|
di{ | delete inside { ... } (keeps the braces) |
da{ | delete around { ... } (includes the braces) |
ci{ / ca{ | change inside / around the braces |
{ hello world }
^^^^^^^^^^^ di{
{ hello world }
^^^^^^^^^^^^^^^ da{
% beyond brackets
With matchit loaded (:packadd matchit, enabled in many distributions such as LazyVim), % also jumps between language constructs such as HTML tags (<div> ↔ </div>) and keyword pairs like if/endif.
For now, keep % meaning matching (), [], {} — that is the core skill.
N% is not "the Nth bracket"Without a count, % matches the bracket. With a count it becomes a file percentage jump:
% → matching bracket
10% → ~10% down the file
50% → approximately halfway through the file
90% → ~90% down the file
% compared with gg and G
gg → beginning of file
G → end of file
50G → line 50
50% → 50% through the file
% → matching bracket
So when you press just %, think MATCH — not percentage.
Practical example
const result = calculateTotal(
items,
tax,
discount,
user.settings.currency
);
^ ← % from "(" jumps straight to ")"
No scrolling, no counting lines, no searching for ), no mouse — you simply ask "where does this bracket close?"
{ and } — move by paragraph
A paragraph ends at a blank line. } jumps to the next paragraph boundary, { to the previous one.
line one
line two <-- } (next blank line)
<-- blank line = paragraph boundary
line three
line four
- Counts repeat the jump:
3}moves forward three paragraphs. - Pairs well with operators:
d}deletes to the next paragraph,>{indents to the previous one.
( and ) — move by sentence
A sentence ends at ., ! or ? followed by whitespace or the end of the line. ) jumps to the next sentence start, ( to the previous one.
First sentence here. Second one starts here. Third.
^ ^ ^
) ) (each ) lands on the next start)
- Counts work here too:
2)moves forward two sentences. - With operators:
d)deletes to the end of the current sentence.
[[ and ]] — move by section (function)
"Section" is Vim's built-in idea of a code block. By default it looks for a { or } in the first column, which is how C functions are laid out.
| Key | Action |
|---|---|
[[ | Previous section start (previous { in column 1) |
]] | Next section start (next { in column 1) |
[] | Previous section end (previous } in column 1) |
][ | Next section end (next } in column 1) |
int add(int a, int b)
{ <-- [[ lands on this brace
return a + b;
}
int sub(int a, int b)
{ <-- ]] lands on this brace
return a - b;
}
The default [[ / ]] is brace-based, so it shines in C-style languages. Treesitter-based motions in LazyVim extend the same keys to jump between real functions and methods in many other languages.
Layer 6: Pattern Navigation
| Key | Action |
|---|---|
| /pattern | Search forward |
| ?pattern | Search backward |
| n | Next match |
| N | Previous match |
| * | Search for word under cursor (forward) |
| # | Search for word under cursor (backward) |
| gd | Go to local definition |
| gD | Go to global definition |
Search Tips
/error → find "error"
/\<error\> → find whole word "error"
/error\c → case-insensitive search
/^import → lines starting with "import"
/function.*{ → lines with function and {
# after search, clear highlight:
:noh (no highlight)
* and # — search the word under the cursor
Instead of typing a pattern, put the cursor on any word and press * to jump to the next occurrence, or # for the previous one.
const total = calculateTotal(items, tax);
^
* → jumps to the next "total"
# → jumps to the previous "total"
Unlike /pattern, * and # automatically wrap the word in whole-word boundaries (\<...\>), so * on count will never stop inside counter.
| Key | Action |
|---|---|
* | Next occurrence of the word under the cursor (forward, whole-word) |
# | Previous occurrence of the word under the cursor (backward, whole-word) |
g* | Next occurrence without word boundaries (matches substrings) |
g# | Previous occurrence without word boundaries |
* and # write the word into the search register, so afterwards n repeats in the same direction and N in the opposite direction.
- Counts jump to the Nth match:
3*lands on the third occurrence. *and#are jumps, soCtrl+otakes you back to where you started (see the Jump List below).
Put the cursor on a variable or function name and tap *, then n/N to step through every usage — faster than typing /name<Enter>.
Jump List — Navigate Your Edit History
Neovim tracks where you jump. Navigate the jump list:
| Key | Action |
|---|---|
| Ctrl+o | Go back in jump list |
| Ctrl+i | Go forward in jump list |
| :jumps | Show the jump list |
A "jump" happens when you: search, use G, gg, {N}G, %, or go to definition.
Position Marks
Save and return to specific positions:
ma → Set mark 'a' at current position
`a → Jump to exact position of mark 'a'
'a → Jump to start of line of mark 'a'
`` → Jump back to previous position
`. → Jump to last change position
`^ → Jump to last insert-exit position
Combining Navigation with Operators
Navigation commands become the motion in operator+motion combos:
d$ → Delete to end of line
y3j → Yank 3 lines down
c/foo → Change up to next "foo"
>} → Indent to next paragraph
vf; → Select to next semicolon
Common Navigation Pitfalls
| Pitfall | Better approach |
|---|---|
| Using jjjjjj to move many lines | Use {N}j or /{pattern} |
| Using hhhh to move many chars | Use f{char}, b, w, 0 |
| Using arrow keys | Use hjkl — keep hands on home row |
| Mouse-clicking to position | Use /{pattern} or {N}G |
Practice Exercises
1. Open any code file in Neovim
2. Practice: go to line 50 with 50G
3. Go back to previous position: Ctrl+o
4. Jump to a function with /function<Enter>
5. Navigate to the matching bracket: %
6. Center the screen on your cursor: zz
Practice
Open the step-by-step drills: practice-folder/nvim/02-modal-editing/normal-navigation-practice.md
(also available via the shell alias n02-navigation).
| Drill | What it trains | Steps |
|---|---|---|
| 1 | Counts | On 0123456789 → 5l → 5l; on the alphabet → 10l |
| 2 | word vs WORD | On user-profile.avatar_url → tap w (9 jumps) vs W (3 jumps) |
| 2B / 2C | w/e vs ge/gE | Walk a line with e; compare de vs dw, ce/cw, ye/yw |
| 3 | 0 ^ $ g_ | End of an indented line → 0 → ^ → $ → g_; repeat |
| 4 | Inline find | On const result = ... → fa, ;, ,, 3f,, t( then dt( |
| 5 | Screen / file | gg, G, 25G, :42, H M L, Ctrl+d/u, Ctrl+f/b, zz zt zb |
| 6 | Paragraph / bracket | } {, ( ), %, [[ ]] |
| 7 | Search | /error, n n n, N, ?error, *, #, :noh |
| 8 | Jump list | gg → /success → 25G → Ctrl+o x4 → Ctrl+i x4 → :jumps |
| 9 | Marks | ma, `a, 'a, ``, `., `^ |
In LazyVim
LazyVim keeps vanilla Normal mode navigation (h, j, k, l, w, b, etc.) completely intact. However, it enhances navigation by bundling flash.nvim.
With flash.nvim, pressing s lets you jump to any location on the screen by typing a 2-character search pattern and selecting the target label.
| Aspect | Vanilla Neovim | LazyVim |
|---|---|---|
| Basic Navigation | h, j, k, l | Same |
| Screen Jumping | Requires plugins (e.g., Hop/Sneak) | flash.nvim pre-configured (s) |