Building a Custom Tabline in Neovim with Lua

Cover Image for Building a Custom Tabline in Neovim with Lua
Rahul M. Juliato
Rahul M. Juliato
#neovim#lua# customization

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.

neovim-custom-tabline-demo-01

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.

neovim-custom-tabline-demo-02


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:

neovim-custom-tabline-demo-03


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.