Roll your own Neovim statusline
tl;dr
You can create a (really) nice statusline quite easily without dedicated plugins:

Neovim's default statusline
The unconfigured statusline looks something like this:

You get:
- The path to the current file
- A modified status indicator (
[+]if there are unsaved changes) - LSP diagnostics (
E:1 W:2) - The cursor position (
42,0-1) - The position of the viewport (
Bot, i.e. bottom of the page)
For my money this is actually a great set of defaults. But if you also spend hours every day in this editor, you may ask yourself, does it spark joy?
For me, an ideal, Marie Kondo-style statusline should be:
- Fancy but understated
- Lightweight
- Hackable
This turns out to be very achievable with nvim's built-in functionality - here's how.
What's in a statusline?
The statusline help page says this about the statusline option:
Contains printf-style "%" items interspersed with normal text, where each item has the form:
%-0{minwid}.{maxwid}{item}
It also lists a bunch of options you can use as the {item}, although for us it's sufficient to note only three:
| Item | Meaning |
|---|---|
%#Bla# | Apply the Bla highlight group to anything that comes next |
%= | Anything before this will be to the left of the status bar; anything after will be to the right |
%{% | Anything after this is evaluated and then re-evaluated in the context of the statusline. This item is closed using %}1. |
Creating a statusline using Lua
First let's create a Lua function which returns a string formatted as per the above advice. I'm putting this in nvim/lua and returning it at the end of the script - this way I can just require() the file from anywhere in my Neovim config.
-- nvim/lua/statusline.lua
return {
render = function()
local active_win = vim.fn.win_getid()
local status_win = tonumber(vim.g.actual_curwin)
if status_win ~= active_win then
return "Statusline for inactive windows"
end
return table.concat({
"Statusline left-aligned stuff",
"%=", -- Left/right separator
"Statusline right-aligned stuff",
})
end,
}We can then set our statusline to call render() like so:
-- nvim/init.lua
vim.opt.statusline = "%{%v:lua.require'statusline'.render()%}"With this setup we can now easily insert arbitrary components into our statusline. You might notice that I also snuck in some special behaviour for lines in non-focussed windows - you don't have to keep this, but all the other kids are doing it.
Our statusline now looks like this:

Setting up some highlight groups
For a truly fancy setup you will probably want to define some custom highlights. You can use preexisting ones, but IMO it's a little clearer to define some dedicated highlight groups. You can put this code at the top of nvim/lua/statusline.lua:
local hl = function(group)
return vim.api.nvim_get_hl(0, {
name = group,
link = false,
create = false,
})
end
local set_hl_groups = function()
local base = hl("StatusLine")
for group, opts in pairs({
ModeNormal = { fg = base.bg, bg = hl("StatusLine").fg },
ModePending = { fg = base.bg, bg = hl("Comment").fg },
ModeVisual = { fg = base.bg, bg = hl("SpecialKey").fg },
ModeInsert = { fg = base.bg, bg = hl("DiffAdded").fg },
ModeCommand = { fg = base.bg, bg = hl("Number").fg },
ModeReplace = { fg = base.bg, bg = hl("Constant").fg },
Bold = { fg = base.fg, bg = base.bg, bold = true },
Dim = { fg = hl("LineNr").fg, bg = base.bg },
}) do
group = "StatusLine" .. group
vim.api.nvim_set_hl(0, group, opts)
opts.fg, opts.bg = opts.bg, opts.fg
vim.api.nvim_set_hl(0, group .. "Inverted", opts)
end
end
-- Compile and apply our custom highlights
set_hl_groups()
-- Re-compile statusline colours when the colorscheme changes
vim.api.nvim_create_autocmd("ColorScheme", {
group = vim.api.nvim_create_augroup("my_statusline", {}),
desc = "Re-apply statusline highlights on colorscheme change",
callback = set_hl_groups,
})set_hl_groups() creates a bunch of highlight groups like "StatusLineBold", "StatusLineBoldInverted", "StatusLineModeNormal", etc. These reuse attributes of existing highlight groups here - we could define our own colours, but these wouldn't look good with every colorscheme.
Creating a component
local mode_component = function()
-- Note: termcodes \19 and \22 are ^S and ^V
---- stylua: ignore
local mode_settings = {
["n"] = { name = "NORMAL", hl = "Normal" },
["no"] = { name = "OP-PENDING", hl = "Pending" },
["nov"] = { name = "OP-PENDING", hl = "Pending" },
["noV"] = { name = "OP-PENDING", hl = "Pending" },
["no\22"] = { name = "OP-PENDING", hl = "Pending" },
["niI"] = { name = "NORMAL", hl = "Normal" },
["niR"] = { name = "NORMAL", hl = "Normal" },
["niV"] = { name = "NORMAL", hl = "Normal" },
["nt"] = { name = "NORMAL", hl = "Normal" },
["ntT"] = { name = "NORMAL", hl = "Normal" },
["v"] = { name = "VISUAL", hl = "Visual" },
["vs"] = { name = "VISUAL", hl = "Visual" },
["V"] = { name = "V-LINE", hl = "Visual" },
["Vs"] = { name = "V-LINE", hl = "Visual" },
["\22"] = { name = "V-BLOCK", hl = "Visual" },
["\22s"] = { name = "V-BLOCK", hl = "Visual" },
["s"] = { name = "SELECT", hl = "Insert" },
["S"] = { name = "S-LINE", hl = "Normal" },
["\19"] = { name = "S-BLOCK", hl = "Normal" },
["i"] = { name = "INSERT", hl = "Insert" },
["ic"] = { name = "INSERT", hl = "Insert" },
["ix"] = { name = "INSERT", hl = "Insert" },
["R"] = { name = "REPLACE", hl = "Replace" },
["Rc"] = { name = "REPLACE", hl = "Replace" },
["Rx"] = { name = "REPLACE", hl = "Replace" },
["Rv"] = { name = "V-REPLACE", hl = "Replace" },
["Rvc"] = { name = "V-REPLACE", hl = "Replace" },
["Rvx"] = { name = "V-REPLACE", hl = "Replace" },
["c"] = { name = "COMMAND", hl = "Command" },
["cv"] = { name = "EX", hl = "Command" },
["ce"] = { name = "EX", hl = "Command" },
["r"] = { name = "REPLACE", hl = "Normal" },
["rm"] = { name = "MORE", hl = "Normal" },
["r?"] = { name = "CONFIRM", hl = "Normal" },
["!"] = { name = "SHELL", hl = "Normal" },
["t"] = { name = "TERMINAL", hl = "Command" },
}
local mode = mode_settings[vim.fn.mode()] or {}
return table.concat({
"%#StatuslineMode" .. mode.hl .. "Inverted" .. "#",
"%#StatuslineMode" .. mode.hl .. "#" .. mode.name,
"%#StatuslineMode" .. mode.hl .. "Inverted" .. "#",
})
endWe:
- Get the current mode using
vim.fn.mode() - Get the corresponding display name and the highlight group
- Construct a string complete with fancy icons for a bubble effect
We can then drop this component into our render() function like so:
return {
render = function()
local active_win = vim.fn.win_getid()
local status_win = tonumber(vim.g.actual_curwin)
if status_win ~= active_win then
return "Statusline for inactive windows"
end
return table.concat({
mode_component(), -- <- New component added here
"%=",
"Statusline right-aligned stuff",
})
end,
}(Note: if you set the mode in the statusline, you might also want to :set noshowmode so it doesn't get duplicated on the row below).
This gets us something like this:

Some gotchas
Gotcha: statusline refresh
If you use a component which updates in the background or with a slight delay, you may find that your statusline doesn't update, e.g. until you start modifying text. In these cases you can use :redrawstatus to trigger an update. E.g. I found I needed to do this in an autocommand to make sure I got snappy updates from information provided by gitsigns.nvim:
vim.api.nvim_create_autocmd("User", {
pattern = "GitSignsUpdate",
group = vim.api.nvim_create_augroup("statusline_git", {}),
command = "redrawstatus",
})Final notes
This is hopefully enough for you to begin to lovingly craft your own statusline that does exactly what you need and nothing more. If you want some more inspiration you can see the actual version I use, which includes components for the current file, Git status, etc. Thanks to Evgeni Chasnovski for kindly pointing this out in response to the first version of this post, which used the slightly less ergonomic %! to evaluate Lua code in the statusline. ↩