Building a Custom Tabline in Neovim with Lua

When people hear "tabs" in an editor, they usually imagine something
like browser tabs in Chrome, Firefox, or even VSCode. But Neovim's
tabs are quite different, they are workspaces, each containing
its own set of windows and splits. Think of them more like tmux's
sessions or Emacs' tab-bar
, rather than VSCode's tab-line that just
lists files.
By default, Neovim’s tabline shows placeholders like [No Name]
or full
buffer names, but I find this neither attractive nor particularly
useful. I prefer something more minimal, closer to how tmux presents
workspaces: numbered slots with just enough styling to highlight the
active one. This way, I avoid the distraction of long file names and
can stay focused on the workspace itself.
In the screenshot below, at the top left, you can see three tabs: the first has three splits (indicated by the blue “3” along with the filename of the selected split), the second is empty, and the third contains another open file.
In this post, I’ll walk you through how I built a pill-style tabline
in Lua, explain some of the design decisions, and show how it fits
neatly into my tmux workflow.
Here’s a preview of the final result: three tabs, numbered, formatted, and highlighted for clarity.
Part 1: Why Customize the Tabline?
Here's the situation:
➖ Default Neovim tabs → Show file names and placeholders, often cluttering the screen.
➖ tmux tabs/panes → Show numbered workspaces, clean and efficient.
➖ Emacs → Has two modes: tab-bar
(like Neovim's tabs) and tab-line
(like VSCode's file tabs).
I wanted my Neovim tabline to look and feel closer to tmux and Emacs' tab-bar. Just numbered "workspaces," styled as pills for clarity.
Here's a preview:
Part 2: Keymaps for Managing Tabs
First, I set up a few keymaps to create, toggle, and navigate tabs:
-- Create a new tab
vim.keymap.set("n", "<leader>tn", ":tabnew<CR>", { desc = "New [t]ab" })
-- Exclude current tab
vim.keymap.set("n", "<leader>tx", ":tabclose<CR>", { desc = "E[x]clude tab" })
-- Toggle showing the tabline
vim.keymap.set("n", "<leader>tt", function()
if vim.o.showtabline == 2 then
vim.o.showtabline = 0
else
vim.o.showtabline = 2
end
end, { desc = "Toggle [t]abs" })
-- Navigate tabs
vim.keymap.set("n", "]t", ":tabnext<CR>", { desc = "Next tab", silent = true })
vim.keymap.set("n", "[t", ":tabprevious<CR>", { desc = "Previous tab", silent = true })
This gives me a lightweight tmux-like navigation inside Neovim.
Part 3: Styling with Highlight Groups
I use custom Catppuccin highlight groups to create the "pill" effect (to match my Neovim, terminal, and tmux Catppuccin theme):
vim.api.nvim_set_hl(0, "TabLine", { bg = "NONE", fg = "#666666" })
vim.api.nvim_set_hl(0, "TabLineFill", { bg = "NONE" })
vim.api.nvim_set_hl(0, "TabLinePillActiveLeft", { fg = "#8aadf4", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillActiveText", { fg = "#1e1e2e", bg = "#8aadf4" })
vim.api.nvim_set_hl(0, "TabLinePillActiveRight", { fg = "#8aadf4", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveLeft", { fg = "#737994", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveText", { fg = "#1e1e2e", bg = "#737994" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveRight",{ fg = "#737994", bg = "#1e1e2e" })
Each tab is enclosed by decorative glyphs (
on the left and
on
the right). Inside, it shows a number, with styling that changes
depending on whether the tab is active or inactive.
Note: your terminal font must support these glyphs. The screenshots use JetBrains Mono Nerd Font.
Part 4: The Lua Function for the Tabline
Finally, I define the tabline function:
vim.o.tabline = "%!v:lua.PillTabline()"
function _G.PillTabline()
local s = ""
local tabs = vim.api.nvim_list_tabpages()
local current = vim.api.nvim_get_current_tabpage()
for i, tab in ipairs(tabs) do
local is_active = (tab == current)
local hl_left = is_active and "%#TabLinePillActiveLeft#" or "%#TabLinePillInactiveLeft#"
local hl_text = is_active and "%#TabLinePillActiveText#" or "%#TabLinePillInactiveText#"
local hl_right = is_active and "%#TabLinePillActiveRight#" or "%#TabLinePillInactiveRight#"
s = s .. hl_left .. ""
s = s .. hl_text .. " " .. i .. " "
s = s .. hl_right .. ""
s = s .. "%#TabLine# "
end
return s
end
Each tab gets a pill-shaped segment, numbered sequentially. No clutter, just a clear workspace indicator.
Part 5: Testing It
For your convinience, here is the full tabline.lua
file:
-- Create a new tab
vim.keymap.set("n", "<leader>tn", ":tabnew<CR>", { desc = "New [t]ab" })
-- Exclude current tab
vim.keymap.set("n", "<leader>tx", ":tabclose<CR>", { desc = "E[x]clude tab" })
-- Toggle showing the tabline
vim.keymap.set("n", "<leader>tt", function()
if vim.o.showtabline == 2 then
vim.o.showtabline = 0
else
vim.o.showtabline = 2
end
end, { desc = "Toggle [t]abs" })
-- Navigate tabs
vim.keymap.set("n", "]t", ":tabnext<CR>", { desc = "Next tab", silent = true })
vim.keymap.set("n", "[t", ":tabprevious<CR>", { desc = "Previous tab", silent = true })
vim.api.nvim_set_hl(0, "TabLine", { bg = "NONE", fg = "#666666" })
vim.api.nvim_set_hl(0, "TabLineFill", { bg = "NONE" })
vim.api.nvim_set_hl(0, "TabLinePillActiveLeft", { fg = "#8aadf4", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillActiveText", { fg = "#1e1e2e", bg = "#8aadf4" })
vim.api.nvim_set_hl(0, "TabLinePillActiveRight", { fg = "#8aadf4", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveLeft", { fg = "#737994", bg = "#1e1e2e" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveText", { fg = "#1e1e2e", bg = "#737994" })
vim.api.nvim_set_hl(0, "TabLinePillInactiveRight",{ fg = "#737994", bg = "#1e1e2e" })
vim.o.tabline = "%!v:lua.PillTabline()"
function _G.PillTabline()
local s = ""
local tabs = vim.api.nvim_list_tabpages()
local current = vim.api.nvim_get_current_tabpage()
for i, tab in ipairs(tabs) do
local is_active = (tab == current)
local hl_left = is_active and "%#TabLinePillActiveLeft#" or "%#TabLinePillInactiveLeft#"
local hl_text = is_active and "%#TabLinePillActiveText#" or "%#TabLinePillInactiveText#"
local hl_right = is_active and "%#TabLinePillActiveRight#" or "%#TabLinePillInactiveRight#"
s = s .. hl_left .. ""
s = s .. hl_text .. " " .. i .. " "
s = s .. hl_right .. ""
s = s .. "%#TabLine# "
end
return s
end
You can test this file standalone without touching your main config with:
nvim --clean -u NONE -c 'luafile tabline.lua' -c 'tabnew' -c 'tabnew'
This opens Neovim clean, loads your tabline, and creates a couple of
tabs so you can see the style right away. \
is the default leader key btw.
Conclusion
I like to think of this as a complement to tmux. tmux manages workspaces across the system, and inside each tmux pane, Neovim provides its own tabbed workspaces. By stripping away file names and fancy descriptions, I keep the focus on structure instead of distractions.
➖ Neovim tabs ≠ browser tabs. They're more like workspaces.
➖ My custom tabline ≈ tmux sessions, but inside Neovim.
➖ The pill design gives enough visual separation without being noisy.
I'll probably refine this over time (maybe adding icons or buffer names when I really need them), but for now this simple approach keeps my Neovim and tmux environments consistent and distraction-free.