visual-multi.nvim
July 23, 2026 · View on GitHub
English · 简体中文
A small, native Lua multi-cursor plugin for Neovim 0.12+.
Important
This project evolved from the original
mg979/vim-visual-multi
repository. The current Lua rewrite was implemented entirely by AI under user
direction. It targets current Neovim only and does not preserve Vim or legacy
vim-visual-multi compatibility.
The implementation uses Extmarks and granular buffer update events.
Motivation
- Keep only the core workflow. Remove legacy compatibility layers and advanced features that are not essential to selecting, navigating, and editing with multiple cursors.
- Improve performance substantially. Use native Lua, Extmarks, batched selection creation, binary-search cursor lookup, and serialized Insert updates without per-keystroke full redraws.
Approximate performance comparison
The following is a single headless run on the same machine with Neovim 0.12.4.
Each line contained one foo, and the benchmark selected every occurrence.
The numbers are indicative rather than a rigorous cross-machine benchmark.
| Matches | Original repository | Lua rewrite | Approx. speedup |
|---|---|---|---|
| 200 | 62.1 ms | 8.3 ms | 7.5× |
| 500 | 111.5 ms | 16.4 ms | 6.8× |
| 1,000 | 166.4 ms | 30.4 ms | 5.5× |
| 2,000 | 357.3 ms | 55.8 ms | 6.4× |
| 10,000 | 1,669.4 ms | 246.7 ms | 6.8× |
Large synchronized Insert sessions also avoid full highlight reconstruction on every keypress and process rapid input through a serialized event queue.
Demo

The demo covers five workflows:
- Select
Colorwith native characterwise Visual mode, press<C-d>to select every occurrence, and appendTokensynchronously. - Press
<C-n>repeatedly to adduserNameoccurrences, then insertactive_at every selection. - Add vertical cursors with
<C-Down>and append to three adjacent lines. - Press
vrepeatedly to expand from characters to words, quotes, and enclosing calls, then replace every selected call synchronously. - Yank different words, paste each value after its matching
=, and continue editing all pasted values together.
The floating panel in the top-right corner displays the recent keystrokes.
Re-record it after installing VHS, ttyd,
and ffmpeg:
./demo/record-demo.sh
Features
- Normal, Insert, and Extend modes with synchronized multi-cursor editing.
- Create selections from the current word, native Visual text, vertical positions, or all literal occurrences.
- Synchronized movement, insert, yank, delete, case conversion, paste, undo,
D,o, andO. - Semantic region expansion and one-level shrink with session-local
v/V. - Fast Lua and Extmark core with batched selection creation and queued Insert updates.
- Compact statusline that follows the region under the real cursor.
- UTF-8-aware, buffer-local sessions with configurable mappings and highlights.
Installation
With lazy.nvim:
{
"yaocccc/visual-multi.nvim",
opts = {},
}
The plugin also loads with its defaults when setup() is not called explicitly.
Default mappings
Start a session
| Mapping | Action |
|---|---|
<C-n> | Select word / next occurrence |
<C-d> | Select all occurrences |
Native Visual + <C-n> | Use the selection and add the next occurrence |
Native Visual + <C-d> | Use the selection and select all occurrences |
<C-Left> / <C-Right> | Start or extend a selection |
<C-Up> / <C-Down> | Add cursor above / below |
<C-x> | Add cursor at current position |
<C-w> | Add the word at current position |
During a session
| Mapping | Action |
|---|---|
n / N | Next / previous occurrence |
q | Remove the region under the real cursor |
] / [ | Focus next / previous region |
v / V | Enter or expand Extend selection / shrink one level |
h j k l w b e 0 ^ $ | Move cursors or extend selections |
i a I A | Enter synchronized Insert mode |
<C-v> | Paste at every cursor in Insert mode |
o / O | Open a new line below / above every cursor and enter Insert mode |
D | Delete from every cursor to the end of its line and enter Insert mode |
~ | In Normal, toggle the character case under every cursor |
u / U | In Extend, convert every selection to lowercase / uppercase |
u / <C-r> | In Normal, undo / redo |
c d x y p | Edit all regions |
<Esc> | Restore the original Normal cursor positions, or end the session from Normal |
Native Visual initialization currently accepts single-line characterwise
selections. <C-Left> and <C-Right> remain available for plugin-managed
selection expansion. In a session, repeated v expands from character to word,
quotes/brackets, and the whole line; V restores the previous level. The h,
l, w, b, and e motions stay within each
cursor's current line; gg and G are not overridden during a session.
Configuration
require("visual-multi").setup({
wrap = true,
case_sensitive = true,
mappings = {
find_next = "<C-n>",
select_all = "<C-d>",
select_left = "<C-Left>",
select_right = "<C-Right>",
add_cursor_up = "<C-Up>",
add_cursor_down = "<C-Down>",
add_cursor = "<C-x>",
add_cursor_word = "<C-w>",
skip_region = false,
remove_region = "q",
insert_paste = "<C-v>",
undo = "u",
redo = "<C-r>",
},
})
Highlights
Every color table is merged with its built-in defaults, so individual fields or
roles can be overridden without repeating the rest. A highlight group name can
still replace a role completely. Table-based settings are reapplied after
ColorScheme.
All configurable highlight roles and their defaults:
highlights = {
cursor = { bg = "#87afff", fg = "#4e4e4e" },
cursor_active = { bg = "#dfdf87", fg = "#4e4e4e" },
insert = { bg = "#4c4e50" },
insert_active = { bg = "#4c4e50" },
selection = { bg = "#005faf" },
selection_active = { bg = "#87afff", fg = "#4e4e4e" },
}
A partial override only needs the changed fields:
highlights = {
selection_active = {
bg = "#ffaf87", -- keeps the default fg = "#4e4e4e"
},
-- cursor = "MyCursorGroup", -- replaces the default cursor table
}
The *_active roles affect only the region under the real cursor; the remaining
regions use their non-active counterpart.
Statusline
The default statusline is a compact flat bar: its background ends with its content instead of filling the row, and it has no font-specific glyph dependency. To override it, pass a formatter:
statusline = function(info)
return ("%%#MyVisualMultiBar# %s %d/%d %%*")
:format(info.mode, info.current, info.total)
end
Set a mapping to false to disable it. Set statusline = false to disable the
statusline replacement. The formatter receives mode, current, total,
pattern, and current selection text fields.
Commands
:VisualMultiNext:VisualMultiAll:VisualMultiAdd:VisualMultiClear:VisualMultiInfo
License
MIT