Skip to main content

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.

Learning Focus

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)
Why hjkl?

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​

KeyMotionPunctuation
wNext word start (punctuation-aware)matters
WNext WORD start (space-delimited only)ignored
bPrevious word start (back)matters
BPrevious WORD startignored
eend of current/next wordmatters
EEnd of current/next WORDignored
geEnd of previous wordmatters
gEEnd of previous WORDignored

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 positionwe
Beginning of wordNext word's startCurrent word's end
Middle of wordNext word's startCurrent word's end
End of wordNext word's startUsually next word's end

b and ge — moving backward​

  • b → previous word start (reverse of w, punctuation-aware).
  • B → previous WORD start (reverse of W).
  • ge → end of previous word (backward equivalent of e).
  • 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
tip

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​

KeyAction
0Go 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 / gkMove 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 line
  • g_ → 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​

KeyAction
HHigh — top of screen
MMiddle of screen
LLow — bottom of screen
Ctrl+d / Alt+dScroll down half screen
Ctrl+u / Alt+uScroll up half screen
Ctrl+f / Alt+Shift+dScroll forward full screen
Ctrl+b / Alt+Shift+uScroll back full screen
Ctrl+eScroll line down (cursor stays)
Ctrl+yScroll line up (cursor stays)
zzCenter screen on cursor
ztPut cursor line at top
zbPut cursor line at bottom
LazyVim Override

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:

KeyAction
Alt+dScroll down half screen (same as Ctrl+d)
Alt+uScroll up half screen (same as Ctrl+u)
Alt+Shift+dScroll down full screen (same as Ctrl+f)
Alt+Shift+uScroll up full screen (same as Ctrl+b)
note

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.

tip

zz is incredibly useful — center the screen when you land on an important line.

Layer 5: File Navigation​

KeyAction
ggGo to file start
GGo to file end
{N}GGo to line N (e.g., 42G)
{N}ggGo to line N (identical to {N}G)
:42Go 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 result

When 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:

CommandMeaning
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.

KeyAction
[[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;
}
tip

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​

KeyAction
/patternSearch forward
?patternSearch backward
nNext match
NPrevious match
*Search for word under cursor (forward)
#Search for word under cursor (backward)
gdGo to local definition
gDGo 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.

KeyAction
*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, so Ctrl+o takes you back to where you started (see the Jump List below).
tip

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:

KeyAction
Ctrl+oGo back in jump list
Ctrl+iGo forward in jump list
:jumpsShow 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​

PitfallBetter approach
Using jjjjjj to move many linesUse {N}j or /{pattern}
Using hhhh to move many charsUse f{char}, b, w, 0
Using arrow keysUse hjkl — keep hands on home row
Mouse-clicking to positionUse /{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&lt;Enter&gt;
5. Navigate to the matching bracket: %
6. Center the screen on your cursor: zz

Practice​

Practice File

Open the step-by-step drills: practice-folder/nvim/02-modal-editing/normal-navigation-practice.md (also available via the shell alias n02-navigation).

DrillWhat it trainsSteps
1CountsOn 0123456789 → 5l → 5l; on the alphabet → 10l
2word vs WORDOn user-profile.avatar_url → tap w (9 jumps) vs W (3 jumps)
2B / 2Cw/e vs ge/gEWalk a line with e; compare de vs dw, ce/cw, ye/yw
30 ^ $ g_End of an indented line → 0 → ^ → $ → g_; repeat
4Inline findOn const result = ... → fa, ;, ,, 3f,, t( then dt(
5Screen / filegg, G, 25G, :42, H M L, Ctrl+d/u, Ctrl+f/b, zz zt zb
6Paragraph / bracket} {, ( ), %, [[ ]]
7Search/error, n n n, N, ?error, *, #, :noh
8Jump listgg → /success → 25G → Ctrl+o x4 → Ctrl+i x4 → :jumps
9Marksma, `a, 'a, ``, `., `^

In LazyVim​

LazyVim Context

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.

AspectVanilla NeovimLazyVim
Basic Navigationh, j, k, lSame
Screen JumpingRequires plugins (e.g., Hop/Sneak)flash.nvim pre-configured (s)

What's Next​