A small, native Lua multi-cursor plugin for Neovim 0.12+.
[!IMPORTANT] This project evolved from the original
mg979/vim-visual-multirepository. The current Lua rewrite was implemented entirely by AI under user direction. It targets current Neovim only and does not preserve Vim or legacyvim-visual-multicompatibility.
The implementation uses Extmarks and granular buffer update events.
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.

The demo covers five workflows:
Color with native characterwise Visual mode, press <C-d> to select
every occurrence, and append Token synchronously.<C-n> repeatedly to add userName occurrences, then insert active_
at every selection.<C-Down> and append to three adjacent lines.v repeatedly to expand from characters to words, quotes, and enclosing
calls, then replace every selected call synchronously.=, 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
D, o, and O.v / V.With lazy.nvim:
{
"yaocccc/visual-multi.nvim",
opts = {},
}
The plugin also loads with its defaults when setup() is not called explicitly.
| 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 |
| 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.
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>",
},
})
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.
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.
:VisualMultiNext:VisualMultiAll:VisualMultiAdd:VisualMultiClear:VisualMultiInfoMIT