A modern, feature-rich notification system for Neovim that transforms the standard vim.notify experience with beautiful UI, smart grouping, and powerful customization options.
demo-notifier.mp4
- π― Smart Positioning - Multiple notification groups (corners) with independent management
- π¨ Beautiful UI - Virtual text rendering with syntax highlighting and custom formatting
- β±οΈ Timeout Management - Automatic dismissal with configurable timeouts per notification
- π ID-based Updates - Update existing notifications instead of creating duplicates
- π History Viewer - Browse all active notifications in a scrollable floating window
- π Custom Formatters - Create your own notification layouts and styling
- β‘ High Performance - Debounced rendering and efficient virtual text handling
- ποΈ Fully Configurable - Every aspect customizable through comprehensive options
Using lazy.nvim
{
"y3owk1n/notifier.nvim",
config = function()
require("notifier").setup({
-- your configuration here
})
end
}Then in your init.lua:
require("notifier").setup()-- Basic setup with defaults, these are all you need to get started, unless you want to fine-tune the details
require("notifier").setup({
animation = {
enabled = true
}
})
-- Now use enhanced vim.notify
vim.notify("Hello, World!")
vim.notify("Warning message", vim.log.levels.WARN)
vim.notify("Error occurred", vim.log.levels.ERROR)
-- Or you can try out the demo that we prepared to see what notifier.nvim can do
require("notifier.demo").run_demo()require("notifier").setup({
-- Notification timeout in milliseconds
default_timeout = 3000,
-- Debounce time for window resize events
resize_debounce_ms = 150,
-- Border style for floating windows
border = "none", -- "none", "single", "double", "rounded", "solid", "shadow"
-- Winblend for floating windows
winblend = 0, -- 0-100 transparency
-- Padding around notification content
padding = {
top = 0,
right = 0,
bottom = 0,
left = 0
},
-- Default notification group
default_group = "bottom-right",
-- Group positioning configurations
group_configs = {
["top-left"] = {
anchor = "NW",
row = function() return 0 end,
col = function() return 0 end,
winblend = 0, -- overrides global winblend
},
["top-center"] = {
anchor = "NW",
row = function() return 0 end,
col = function() return vim.o.columns / 2 end,
center_mode = "horizontal", -- Center horizontally only
},
["top-right"] = {
anchor = "NE",
row = function() return 0 end,
col = function() return vim.o.columns end,
},
["left-center"] = {
anchor = "NW",
row = function() return (vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0)) / 2 end,
col = function() return 0 end,
center_mode = "vertical", -- Center vertically only
},
["center"] = {
anchor = "NW",
row = function() return (vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0)) / 2 end,
col = function() return vim.o.columns / 2 end,
center_mode = "true", -- Center both horizontally and vertically
},
["right-center"] = {
anchor = "NE",
row = function() return (vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0)) / 2 end,
col = function() return vim.o.columns end,
center_mode = "vertical", -- Center vertically only
},
["bottom-left"] = {
anchor = "SW",
row = function() return vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0) end,
col = function() return 0 end,
},
["bottom-center"] = {
anchor = "SW",
row = function() return vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0) end,
col = function() return vim.o.columns / 2 end,
center_mode = "horizontal", -- Center horizontally only
},
["bottom-right"] = {
anchor = "SE",
row = function() return vim.o.lines - vim.o.cmdheight - (vim.o.laststatus > 0 and 1 or 0) end,
col = function() return vim.o.columns end,
},
},
-- Width configuration
width = {
min_width = 20, -- Minimum notification width
max_width = nil, -- Maximum width (nil = auto-calculate)
preferred_width = 50, -- Preferred width when content fits
max_width_percentage = 0.4, -- Maximum width as percentage of screen
adaptive = true, -- Automatically adjust width based on content
wrap_text = true, -- Enable text wrapping for long lines
wrap_at_words = true, -- Wrap at word boundaries when possible
},
-- Icons for different log levels
icons = {
[vim.log.levels.TRACE] = "σ° ",
[vim.log.levels.DEBUG] = "ο ",
[vim.log.levels.INFO] = "ο ",
[vim.log.levels.WARN] = "ο± ",
[vim.log.levels.ERROR] = "ο ",
}
-- Formatters
notif_formatter = require("notifier").formatters.default_notif,
notif_history_formatter = require("notifier").formatters.default_history,
-- Animation
animation = {
enabled = false, -- animation is off by default
fade_in_duration = 300,
fade_out_duration = 300,
},
})require("notifier").setup({
default_timeout = 5000,
border = "rounded",
padding = { top = 1, right = 2, bottom = 1, left = 2 },
group_configs = {
["bottom-right"] = {
anchor = "SE",
row = function() return vim.o.lines - 3 end, -- Leave more space from bottom
col = function() return vim.o.columns - 1 end,
winblend = 20, -- Semi-transparent
}
},
-- Custom icons
icons = {
[vim.log.levels.ERROR] = "β ",
[vim.log.levels.WARN] = "β ",
[vim.log.levels.INFO] = "βΉ ",
[vim.log.levels.DEBUG] = "π ",
[vim.log.levels.TRACE] = "π ",
}
})-- Simple notification
vim.notify("Task completed successfully!")
-- With log level
vim.notify("Configuration reloaded", vim.log.levels.INFO)
-- Multi-line message
vim.notify("Build failed:\n- Syntax error on line 42\n- Missing dependency")notifier-demo-1.mov
-- Notification with custom timeout and icon
vim.notify("Long running task started", vim.log.levels.INFO, {
timeout = 10000, -- 10 seconds
icon = "β³ "
})
-- Target specific group
vim.notify("Debug info", vim.log.levels.DEBUG, {
group_name = "top-left",
timeout = 0 -- Always there unless manually dismissed
})
-- Updateable notification with ID
vim.notify("Downloading... 0%", vim.log.levels.INFO, {
id = "download-progress"
})
-- Update the same notification
vim.notify("Downloading... 50%", vim.log.levels.INFO, {
id = "download-progress" -- Same ID updates existing
})
vim.notify("Download complete!", vim.log.levels.INFO, {
id = "download-progress"
})
-- Inline formatter with custom data
vim.notify("π₯οΈ Server", vim.log.levels.INFO, {
id = "server-status",
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local status_icon = data.online and "π’" or "π΄"
local status_text = data.online and "ONLINE" or "OFFLINE"
local status_color = data.online and "String" or "ErrorMsg"
return {
{ display_text = opts.line, hl_group = "NotifierInfo", is_virtual = true },
{ display_text = data.name, hl_group = "Identifier", is_virtual = true },
{ display_text = " " .. status_icon .. " ", hl_group = status_color, is_virtual = true },
{ display_text = status_text, hl_group = status_color, is_virtual = true },
data.uptime and { display_text = " (up " .. data.uptime .. ")", hl_group = "Comment", is_virtual = true } or nil,
}
end,
_notif_formatter_data = {
name = "prod-api-01",
online = true,
uptime = "2d 5h"
}
})
-- Update server status with new data
vim.notify("", vim.log.levels.WARN, {
id = "server-status", -- Same ID updates the notification
_notif_formatter_data = {
name = "prod-api-01",
online = false,
uptime = nil
}
})notifier-demo-2.mov
-- Use custom highlight group
vim.notify("Special message", vim.log.levels.INFO, {
hl_group = "MyCustomHighlight"
})Define your highlight group:
vim.api.nvim_set_hl(0, "MyCustomHighlight", {
fg = "#ff6b6b",
bold = true
})Create your own notification layouts with powerful formatting options:
-- Custom formatter function
local function my_formatter(opts)
local notif = opts.notif
local line = opts.line
local config = opts.config
return {
{ display_text = ">> ", hl_group = "Comment", is_virtual = true },
{ display_text = line, hl_group = notif.hl_group, is_virtual = true },
{ display_text = " <<", hl_group = "Comment", is_virtual = true },
}
end
require("notifier").setup({
notif_formatter = my_formatter
})notifier-demo-3.mov
Pass custom data and formatters for specific notifications:
-- Progress bar formatter with custom data
vim.notify("", vim.log.levels.INFO, {
id = "progress-bar",
timeout = 10000,
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local progress = data.progress or 0
local task = data.task or "Processing"
local total_width = 20
local filled = math.floor((progress / 100) * total_width)
local empty = total_width - filled
local bar = "β" .. string.rep("β", filled) .. string.rep("β", empty) .. "β"
local percentage = string.format("%3d%%", progress)
return {
{ display_text = data.icon or "β³ ", hl_group = "NotifierInfo", is_virtual = true },
{ display_text = task .. ": ", hl_group = "NotifierInfo", is_virtual = true },
{ display_text = bar, hl_group = progress == 100 and "NotifierInfo" or "Comment", is_virtual = true },
{ display_text = " " .. percentage, hl_group = "NotifierInfo", is_virtual = true },
}
end,
_notif_formatter_data = {
progress = 45,
task = "Downloading files",
icon = "π₯ "
}
})
-- Update progress (same ID with new data)
vim.notify("", vim.log.levels.INFO, {
id = "progress-bar",
_notif_formatter_data = {
progress = 75,
task = "Downloading files",
icon = "π₯ "
}
})
-- Complete
vim.notify("", vim.log.levels.INFO, {
id = "progress-bar",
timeout = 3000,
_notif_formatter_data = {
progress = 100,
task = "Download complete",
icon = "β
"
}
})notifier-demo-4.mov
-- Git status formatter with rich data
vim.notify("", vim.log.levels.INFO, {
id = "git-status",
timeout = 8000,
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local parts = {}
-- Title
table.insert(parts, { display_text = "πΏ Git Status", hl_group = "NotifierInfo", is_virtual = true })
if data.branch then
table.insert(parts, { display_text = " on ", hl_group = "Comment", is_virtual = true })
table.insert(parts, { display_text = data.branch, hl_group = "String", is_virtual = true })
end
-- Stats with colors
if data.added and data.added > 0 then
table.insert(parts, { display_text = " +" .. data.added, hl_group = "diffAdded", is_virtual = true })
end
if data.modified and data.modified > 0 then
table.insert(parts, { display_text = " ~" .. data.modified, hl_group = "diffChanged", is_virtual = true })
end
if data.deleted and data.deleted > 0 then
table.insert(parts, { display_text = " -" .. data.deleted, hl_group = "diffRemoved", is_virtual = true })
end
return parts
end,
_notif_formatter_data = {
branch = "feature/new-ui",
added = 5,
modified = 3,
deleted = 1
}
})
-- LSP diagnostic summary formatter
vim.notify("", vim.log.levels.WARN, {
id = "lsp-diagnostics",
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local parts = {
{ display_text = "π Diagnostics: ", hl_group = "NotifierInfo", is_virtual = true }
}
if data.errors > 0 then
table.insert(parts, { display_text = " " .. data.errors, hl_group = "DiagnosticError", is_virtual = true })
end
if data.warnings > 0 then
table.insert(parts, { display_text = " " .. data.warnings, hl_group = "DiagnosticWarn", is_virtual = true })
end
if data.info > 0 then
table.insert(parts, { display_text = "βΉ " .. data.info, hl_group = "DiagnosticInfo", is_virtual = true })
end
if data.hints > 0 then
table.insert(parts, { display_text = " " .. data.hints, hl_group = "DiagnosticHint", is_virtual = true })
end
return parts
end,
_notif_formatter_data = {
errors = 2,
warnings = 5,
info = 3,
hints = 1
}
})
-- Build status with timing information
vim.notify("", vim.log.levels.INFO, {
id = "build-status",
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local icon = data.status == "success" and "β
" or
data.status == "error" and "β" or "β³"
local color = data.status == "success" and "NotifierInfo" or
data.status == "error" and "NotifierError" or "NotifierWarn"
return {
{ display_text = icon .. " Build ", hl_group = color, is_virtual = true },
{ display_text = data.status, hl_group = color, is_virtual = true },
data.duration and { display_text = " (" .. data.duration .. "s)", hl_group = "Comment", is_virtual = true } or nil,
data.target and { display_text = " [" .. data.target .. "]", hl_group = "Identifier", is_virtual = true } or nil,
}
end,
_notif_formatter_data = {
status = "success",
duration = 2.5,
target = "release"
}
})notifier-demo-5.mov
-- Function to update download progress with rich visualization
local function update_download_progress(filename, current, total)
local progress = math.floor((current / total) * 100)
local speed = current > 0 and string.format("%.1f MB/s", (current / 1024 / 1024)) or "0 MB/s"
vim.notify("", vim.log.levels.INFO, {
id = "download-" .. filename,
timeout = progress == 100 and 3000 or 15000,
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local bar_width = 25
local filled = math.floor((data.progress / 100) * bar_width)
local bar = string.rep("β", filled) .. string.rep("β", bar_width - filled)
return {
{ display_text = "π ", hl_group = "Directory", is_virtual = true },
{ display_text = data.filename, hl_group = "NotifierInfo", is_virtual = true },
{ display_text = " [", hl_group = "Comment", is_virtual = true },
{ display_text = bar, hl_group = data.progress == 100 and "String" or "Comment", is_virtual = true },
{ display_text = "] ", hl_group = "Comment", is_virtual = true },
{ display_text = data.progress .. "%", hl_group = "Number", is_virtual = true },
{ display_text = " @ " .. data.speed, hl_group = "Comment", is_virtual = true },
}
end,
_notif_formatter_data = {
filename = filename,
progress = progress,
current = current,
total = total,
speed = speed
}
})
end
-- Usage
update_download_progress("large-file.zip", 0, 100) -- 0%
update_download_progress("large-file.zip", 50, 100) -- 50%
update_download_progress("large-file.zip", 100, 100) -- 100%notifier-demo-6.mov
-- Show notification history
require("notifier").show_history()
---Dismiss all active notifications immediately or with animation
---@param opts? boolean|{ animated?: boolean, stagger?: number } Options for dismissal
require("notifier").dismiss_all(opts)-- Add to your init.lua
vim.keymap.set("n", "<leader>nh", function()
require("notifier").show_history()
end, { desc = "Show notification history" })
vim.keymap.set("n", "<leader>nd", function()
require("notifier").dismiss_all()
end, { desc = "Dismiss all notifications" })Organize notifications by positioning them in different screen areas:
-- Bottom right (default)
vim.notify("System ready", vim.log.levels.INFO)
-- Top right for less intrusive messages
vim.notify("Background task completed", vim.log.levels.INFO, {
group_name = "top-right"
})
-- Top left for debug information
vim.notify("Variable value: " .. tostring(value), vim.log.levels.DEBUG, {
group_name = "top-left"
})
-- Bottom left for status updates
vim.notify("Syncing files...", vim.log.levels.INFO, {
group_name = "bottom-left",
id = "sync-status"
})Customize colors by overriding these highlight groups:
-- Main notification styling
vim.api.nvim_set_hl(0, "NotifierNormal", { bg = "#1a1a1a", fg = "#ffffff" })
vim.api.nvim_set_hl(0, "NotifierBorder", { fg = "#444444" })
-- Level-specific colors
vim.api.nvim_set_hl(0, "NotifierError", { fg = "#ff6b6b", bold = true })
vim.api.nvim_set_hl(0, "NotifierWarn", { fg = "#feca57", bold = true })
vim.api.nvim_set_hl(0, "NotifierInfo", { fg = "#48cae4" })
vim.api.nvim_set_hl(0, "NotifierDebug", { fg = "#a8a8a8" })
vim.api.nvim_set_hl(0, "NotifierTrace", { fg = "#6c757d" })
-- History window styling
vim.api.nvim_set_hl(0, "NotifierHistoryNormal", { bg = "#0d1117" })
vim.api.nvim_set_hl(0, "NotifierHistoryBorder", { fg = "#30363d" })
vim.api.nvim_set_hl(0, "NotifierHistoryTitle", { fg = "#f0f6fc", bold = true })-- Enhanced LSP progress with inline formatters
---Setup a progress spinner for LSP.
---@return nil
local function setup_progress_spinner_custom()
local spinner_chars = { "β ", "β ", "β Ή", "β Έ", "β Ό", "β ΄", "β ¦", "β §", "β ", "β " }
local last_spinner = 0
local spinner_idx = 1
---@type table<string, uv.uv_timer_t|nil>
local active_timers = {}
vim.lsp.handlers["$/progress"] = function(_, result, ctx)
local client = vim.lsp.get_client_by_id(ctx.client_id)
if not client or type(result.value) ~= "table" then
return
end
local value = result.value
local token = result.token
local is_complete = value.kind == "end"
local has_percentage = value.percentage ~= nil
local function render()
local progress_data = {
percentage = value.percentage or nil,
description = value.title or "Loading workspace",
file_progress = value.message or nil,
}
if is_complete then
progress_data.description = "Done"
progress_data.file_progress = nil
end
local icon
if is_complete then
icon = "ο "
else
local now = vim.uv.hrtime()
if now - last_spinner > 80e6 then
spinner_idx = (spinner_idx % #spinner_chars) + 1
last_spinner = now
end
icon = spinner_chars[spinner_idx]
end
vim.notify("", vim.log.levels.INFO, {
id = string.format("lsp_progress_%s_%s", client.name, token),
title = client.name,
_notif_formatter = function(opts)
local notif = opts.notif
local _notif_formatter_data = notif._notif_formatter_data
if not _notif_formatter_data then
return {}
end
local separator = { display_text = " " }
local icon_hl = notif.hl_group or opts.log_level_map[notif.level].hl_group
local percent_text = _notif_formatter_data.percentage
and string.format("%3d%%", _notif_formatter_data.percentage)
or nil
local description_text = _notif_formatter_data.description
local file_progress_text = _notif_formatter_data.file_progress or nil
local client_name = client.name
---@type Notifier.FormattedNotifOpts[]
local entries = {}
if icon then
table.insert(entries, { display_text = icon, hl_group = icon_hl })
table.insert(entries, separator)
end
if percent_text then
table.insert(entries, { display_text = percent_text, hl_group = "CmdHistoryIdentifier" })
table.insert(entries, separator)
end
table.insert(entries, { display_text = description_text, hl_group = "Comment" })
if file_progress_text then
table.insert(entries, separator)
table.insert(entries, { display_text = file_progress_text, hl_group = "Removed" })
end
if client_name then
table.insert(entries, separator)
table.insert(entries, { display_text = client_name, hl_group = "ErrorMsg" })
end
return entries
end,
_notif_formatter_data = progress_data,
})
end
render()
if not has_percentage then
if not is_complete then
local timer = active_timers[token]
if not timer or timer:is_closing() then
timer = vim.uv.new_timer()
active_timers[token] = timer
end
if timer then
timer:start(0, 150, function()
vim.schedule(render)
end)
end
else
local timer = active_timers[token]
if timer and not timer:is_closing() then
timer:stop()
timer:close()
active_timers[token] = nil
end
vim.schedule(render)
end
end
end
end-- Git status with rich formatting and data
local function show_git_status(branch, stats)
vim.notify("", vim.log.levels.INFO, {
id = "git-status",
timeout = 8000,
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local parts = {
{ display_text = "πΏ ", hl_group = "String", is_virtual = true },
{ display_text = data.branch, hl_group = "Identifier", is_virtual = true },
}
if data.stats.ahead > 0 then
table.insert(parts, { display_text = " β" .. data.stats.ahead, hl_group = "diffAdded", is_virtual = true })
end
if data.stats.behind > 0 then
table.insert(parts, { display_text = " β" .. data.stats.behind, hl_group = "diffRemoved", is_virtual = true })
end
if data.stats.modified > 0 then
table.insert(parts, { display_text = " ~" .. data.stats.modified, hl_group = "diffChanged", is_virtual = true })
end
if data.stats.untracked > 0 then
table.insert(parts, { display_text = " +" .. data.stats.untracked, hl_group = "diffAdded", is_virtual = true })
end
return parts
end,
_notif_formatter_data = {
branch = branch,
stats = stats
}
})
end
-- Usage
show_git_status("main", { ahead = 2, behind = 0, modified = 3, untracked = 1 })
-- Test results with detailed breakdown
vim.notify("", vim.log.levels.INFO, {
id = "test-results",
timeout = 10000,
_notif_formatter = function(opts)
local data = opts.notif._notif_formatter_data
local icon = data.passed == data.total and "β
" or "β"
local color = data.passed == data.total and "String" or "ErrorMsg"
return {
{ display_text = icon .. " Tests: ", hl_group = color, is_virtual = true },
{ display_text = data.passed .. "/" .. data.total, hl_group = color, is_virtual = true },
{ display_text = " passed", hl_group = "Comment", is_virtual = true },
data.duration and { display_text = " (" .. data.duration .. "ms)", hl_group = "Comment", is_virtual = true } or nil,
data.coverage and { display_text = " " .. data.coverage .. "% coverage", hl_group = "Number", is_virtual = true } or nil,
}
end,
_notif_formatter_data = {
passed = 45,
total = 48,
duration = 2340,
coverage = 87.5
}
})- Neovim 0.10+
- A terminal that supports Unicode icons (optional, for best experience)
Placements seems to be off for bottom and center:
- The defaults are calculated based on
cmdheightandlaststatus, you can override them as needed - Ensure that nothing is changing these values after the plugin is setup
- If you're having some other plugins or autocomds that will alter these 2 values anytime, you can try setting the following autocmd
local old_laststatus = vim.o.laststatus
local old_cmdheight = vim.o.cmdheight
vim.api.nvim_create_autocmd("OptionSet", {
callback = function()
local notifier = require("notifier")
local new_laststatus = vim.o.laststatus
local new_cmdheight = vim.o.cmdheight
if new_laststatus ~= old_laststatus or new_cmdheight ~= old_cmdheight then
old_laststatus = new_laststatus
old_cmdheight = new_cmdheight
-- let the plugin recalculate positions
notifier._internal.utils.cache_config_group_row_col()
end
end,
})Notifications not showing:
- Ensure you've called
require("notifier").setup() - Check that your Neovim version is 0.10+
Icons not displaying:
- Install a Nerd Font and set it as your terminal font
- Or customize the
iconsconfig with plain text alternatives
Performance issues:
- Reduce
default_timeoutfor faster cleanup - Consider using fewer notification groups
Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.