Absolute justice with relative context — a Neovim plugin that shows both absolute and relative line numbers simultaneously, making pair programming, code reviews, and remote collaboration effortless.
When combined with statuscol.nvim, gitsigns.nvim, and your LSP, line-justice anchors a complete, information-dense statuscolumn — fold indicators, git change markers, diagnostic signs, and dual line numbers, all in one clean gutter.
You use relative line numbers in NeoVim. Your colleagues don't.
You're pair programming and your colleague says "Hey, there's a bug on line 42." You look at your screen — is that 42 above or 42 below the cursor? They're looking at a different part of the file. You move the cursor. Line numbers change. Confusion ensues. You both waste five minutes finding the same line.
Is it near the top? The middle? Nobody knows.
LineJustice shows you both numbers at once — every line, always.
42 6 function handleRequest(req, res) {
43 5 const user = await getUser(req.params.id)
44 4 if (!user) {
45 3 return res.status(404).json({ error: 'Not found' })
46 2 }
47 1 ...
48 ← cursor is here
49 1 return res.json(user)
50 2 }
Now when your colleague says "line 42", you both instantly see the same thing. When you need to jump, you use the relative number. When you reference, you use the absolute. No confusion.
diagnostic/signs namespace for inline error, warning, hint, and info markersline-justice is a statuscol.nvim segment provider. It owns:
It does not call statuscol.setup(). You wire require("line-justice").segment into your own statuscol config. This means line-justice never conflicts with your statuscol configuration — you remain in full control of what else appears in the statuscolumn.
setup() automatically sets vim.o.number = true and vim.o.relativenumber = true. Both are required for statuscol to populate args.lnum and args.relnum correctly.
{
"zaakiy/line-justice.nvim",
dependencies = { "luukvbaal/statuscol.nvim", "lewis6991/gitsigns.nvim" },
lazy = false,
config = function()
local lj = require("line-justice")
lj.setup()
local builtin = require("statuscol.builtin")
require("statuscol").setup({
relculright = true,
segments = {
{ text = { builtin.foldfunc }, click = "v:lua.ScFa" },
{ sign = { namespace = { "gitsigns" }, maxwidth = 1, colwidth = 1, auto = true }, click = "v:lua.ScSa" },
{ sign = { namespace = { "diagnostic/signs" }, maxwidth = 2, auto = true }, click = "v:lua.ScSa" },
{ sign = { name = { ".*" }, maxwidth = 2, colwidth = 1, auto = true, wrap = true }, click = "v:lua.ScSa" },
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
end,
}
Note: Packer is untested. Looking for packer users to contribute any corrections.
use {
"zaakiy/line-justice.nvim",
requires = { "luukvbaal/statuscol.nvim", "lewis6991/gitsigns.nvim" },
config = function()
local lj = require("line-justice")
lj.setup()
local builtin = require("statuscol.builtin")
require("statuscol").setup({
relculright = true,
segments = {
{ text = { builtin.foldfunc }, click = "v:lua.ScFa" },
{ sign = { namespace = { "gitsigns" }, maxwidth = 1, colwidth = 1, auto = true }, click = "v:lua.ScSa" },
{ sign = { namespace = { "diagnostic/signs" }, maxwidth = 2, auto = true }, click = "v:lua.ScSa" },
{ sign = { name = { ".*" }, maxwidth = 2, colwidth = 1, auto = true, wrap = true }, click = "v:lua.ScSa" },
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
end,
}
Clone luukvbaal/statuscol.nvim, lewis6991/gitsigns.nvim, and zaakiy/line-justice.nvim, add them to your runtimepath, then in your init.lua:
local lj = require("line-justice")
local builtin = require("statuscol.builtin")
lj.setup()
require("statuscol").setup({
relculright = true,
segments = {
{ text = { builtin.foldfunc }, click = "v:lua.ScFa" },
{ sign = { namespace = { "gitsigns" }, maxwidth = 1, colwidth = 1, auto = true }, click = "v:lua.ScSa" },
{ sign = { namespace = { "diagnostic/signs" }, maxwidth = 2, auto = true }, click = "v:lua.ScSa" },
{ sign = { name = { ".*" }, maxwidth = 2, colwidth = 1, auto = true, wrap = true }, click = "v:lua.ScSa" },
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
Call require("line-justice").setup(opts) with any options you want to override. All keys are optional — omitting them uses the defaults shown below.
local lj = require("line-justice")
lj.setup({
line_numbers = {
-- theme (string | nil)
-- Name of a built-in colour palette, or nil to auto-detect from your
-- active colorscheme. Default: "Horizon".
theme = "Horizon",
-- overrides (table | nil)
-- Per-key colour tweaks applied on top of the theme or auto-detect.
-- Any key you omit is left exactly as the theme/auto-detect defines it.
-- All keys are optional; provide only the ones you want to change.
overrides = {
-- CursorLine = { fg = "#FF966C", bold = true },
-- AbsoluteAbove = { fg = "#565f89" },
-- AbsoluteBelow = { fg = "#41664f" },
-- RelativeAbove = { fg = "#7b9ac7" },
-- RelativeBelow = { fg = "#6aa781" },
-- WrappedLine = { fg = "#565f89", italic = true },
},
-- fade (table | nil)
-- Distance-based colour fading. Line numbers progressively desaturate
-- toward grey as their distance from the cursor increases.
-- Relative numbers fade more aggressively (they ARE the distance signal).
-- Absolute numbers fade subtly so they stay readable as reference points.
fade = {
-- enabled (boolean)
-- Master switch. Set to false to disable all fading. Default: true.
enabled = true,
-- bands (table)
-- Distance thresholds (in lines) that define the four fade tiers.
-- Lines within `near` rows → full colour (Near tier).
-- Lines within `mid` rows → light fade (Mid tier).
-- Lines within `far` rows → stronger fade (Far tier).
-- Lines beyond `far` rows → maximum fade (Distant tier).
-- Absolute numbers only use Near and Distant (two tiers).
bands = { near = 5, mid = 15, far = 30 },
-- relative_strength (number, 0.0–1.0)
-- How far relative numbers fade at maximum distance.
-- 0.0 = no fade (original colour). 1.0 = fully desaturated grey.
-- Default: 0.75 — a strong fade that makes distant numbers recede.
relative_strength = 0.75,
-- absolute_strength (number, 0.0–1.0)
-- How far absolute numbers fade at maximum distance.
-- Kept lower than relative_strength so absolute numbers stay readable.
-- Default: 0.30 — a subtle fade that preserves readability.
absolute_strength = 0.30,
},
},
wrapped_lines = {
-- indicator (string)
-- Named indicator preset shown in the gutter of soft-wrapped
-- continuation lines, centred in the gutter width. Default: "Bar".
--
-- "None" — blank gutter
-- "Arrow" — ↳
-- "Chevron" — ›
-- "Dot" — ·
-- "Ellipsis" — …
-- "Bar" — │
-- "Custom" — use the string in wrapped_lines.custom
indicator = "Bar",
-- custom (string)
-- Only used when indicator = "Custom".
-- Set this to any character or short string you want to display.
-- Examples: "»", "⤷", "▸", "→", "╰"
-- custom = "⤷",
},
})
Because line-justice never calls statuscol.setup(), you have complete freedom over what else appears in the statuscolumn. The recommended config composes five segments into a single, information-dense gutter:
local builtin = require("statuscol.builtin")
require("statuscol").setup({
relculright = true,
segments = {
{ text = { builtin.foldfunc }, click = "v:lua.ScFa" },
{ sign = { namespace = { "gitsigns" }, maxwidth = 1, colwidth = 1, auto = true }, click = "v:lua.ScSa" },
{ sign = { namespace = { "diagnostic/signs" }, maxwidth = 2, auto = true }, click = "v:lua.ScSa" },
{ sign = { name = { ".*" }, maxwidth = 2, colwidth = 1, auto = true, wrap = true }, click = "v:lua.ScSa" },
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
Reading left to right, each segment adds a distinct layer of information:
builtin.foldfunc — fold columnRenders NeoVim's native fold indicators using statuscol's built-in fold function. Shows ▶ on foldable lines and │ on open fold contents. Clicking it opens or closes the fold (ScFa). Requires foldmethod to be set (e.g. treesitter, indent, or expr).
Why it's here: Code navigation. Collapse functions, classes, or blocks you're not working on. The fold column only appears when there are folds — it takes no space otherwise.
gitsigns namespace — git change markersRenders gitsigns.nvim signs in a dedicated 1-character column. Shows │ for modified lines, ▎ for added lines, and ▁ for deleted lines (exact characters depend on your gitsigns config). Clicking a sign triggers the gitsigns click handler (ScSa).
Why it's here: At-a-glance diff awareness. You can see exactly which lines have changed since the last commit without leaving the file. Invaluable during code review and when rebasing.
diagnostic/signs namespace — LSP diagnosticsRenders LSP diagnostic signs (E errors, W warnings, H hints, I info) from the diagnostic/signs namespace. maxwidth = 2 allows up to two diagnostic signs per line.
Why it's here: Inline error visibility. You see problems the moment your LSP reports them, right next to the line number — no need to scan the statusline or run :lua vim.diagnostic.open_float().
name = { ".*" } — catch-all signsA catch-all segment that renders any other signs not captured by the two namespace segments above — debugger breakpoints, bookmarks, test results, and anything else. wrap = true means signs on wrapped continuation lines are shown on the first real line.
Why it's here: Future-proofing. Any plugin that places a sign (DAP breakpoints, neotest, marks.nvim, etc.) will appear here automatically without any additional config.
lj.segment — dual line numbersThe line-justice segment itself. Absolute line number on the left, relative distance on the right. See The Solution above.
Why it's last: The numbers are the anchor. Everything to the left is contextual metadata about the line; the numbers are the line's identity. Placing them rightmost keeps them closest to the code.
If you don't use gitsigns or an LSP, you can strip the config down to just the essentials:
local builtin = require("statuscol.builtin")
require("statuscol").setup({
segments = {
{ text = { builtin.foldfunc }, click = "v:lua.ScFa" },
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
Any statuscol.setup() key (relculright, bt_ignore, ft_ignore, etc.) can be passed freely alongside the segments:
require("statuscol").setup({
relculright = true,
bt_ignore = { "nofile" },
ft_ignore = { "NvimTree", "neo-tree" },
segments = {
-- your segments here
},
})
When a line is too long for the window and wraps, NeoVim renders the continuation as a virtual line. wrapped_lines.indicator controls what appears in the gutter of those virtual lines, centred in the gutter width.
| Name | Character | Description |
|---|---|---|
"None" |
(blank) | No character — gutter is fully empty |
"Arrow" |
↳ | Classic turn-down arrow — "continued from above" |
"Chevron" |
› | Single right-pointing chevron — lightweight directional hint |
"Dot" |
· | Middle dot / interpunct — subtle and minimal |
"Ellipsis" |
… | Horizontal ellipsis — "more content continues" |
"Bar" |
│ | Thin vertical bar — structural / tree-style (default) |
"Custom" |
your string | Whatever you put in wrapped_lines.custom |
Set indicator = "Custom" and put your character in custom:
wrapped_lines = {
indicator = "Custom",
custom = "⤷", -- or: "»", "▸", "→", "╰", or any string you like
},
The indicator inherits the WrappedLine colour from your line_numbers theme or overrides:
line_numbers = {
theme = "Horizon",
overrides = {
WrappedLine = { fg = "#ff9e64", italic = true }, -- change indicator colour
},
},
Line numbers progressively desaturate toward grey as their distance from the cursor increases, so the numbers closest to you shout and the ones far away whisper.
Fading is enabled by default. To disable it:
lj.setup({
line_numbers = {
fade = { enabled = false },
},
})
The bands table controls the distance thresholds (in lines) for each tier:
| Tier | Condition | Default |
|---|---|---|
| Near | distance <= near |
≤ 5 lines |
| Mid | distance <= mid |
≤ 15 lines |
| Far | distance <= far |
≤ 30 lines |
| Distant | distance > far |
> 30 lines |
relative_strength and absolute_strength control how far the colour desaturates at maximum distance. 0.0 keeps the original colour; 1.0 produces fully grey.
lj.setup({
line_numbers = {
fade = {
enabled = true,
bands = { near = 5, mid = 15, far = 30 },
relative_strength = 0.75, -- relative numbers fade strongly
absolute_strength = 0.30, -- absolute numbers fade subtly
},
},
})
Three colour themes ship out of the box:
| Name | Vibe | Best with |
|---|---|---|
"Horizon" |
Cool blue-purple above, green below | TokyoNight, Catppuccin Mocha, any dark theme |
"Dawn" |
Warm amber and rose tones | Rosé Pine, Catppuccin Latte, Gruvbox |
"Midnight" |
Cool monochrome blue-greys | GitHub Dark, Zephyr, Moonfly |
lj.setup({ line_numbers = { theme = "Horizon" } }) -- default
-- lj.setup({ line_numbers = { theme = "Dawn" } })
-- lj.setup({ line_numbers = { theme = "Midnight" } })
Set theme = nil to auto-detect colours from your active colorscheme instead.
You can register your own colour themes at runtime using the theme registry:
local lj = require("line-justice")
-- 1. Define and register your theme
lj.themes.register({
name = "Forest",
description = "Deep greens and mossy tones.",
author = "Your Name", -- optional
colors = {
CursorLine = { fg = "#a8ff78", bold = true },
AbsoluteAbove = { fg = "#4a7c59" },
AbsoluteBelow = { fg = "#2e5b3a" },
RelativeAbove = { fg = "#6dbf8a" },
RelativeBelow = { fg = "#4c9e6a" },
WrappedLine = { fg = "#4a7c59", italic = true },
},
})
-- 2. Use it in setup
lj.setup({
line_numbers = { theme = "Forest" },
})
-- 3. Wire the segment
require("statuscol").setup({
segments = {
{ text = { lj.segment }, click = "v:lua.ScLa" },
},
})
| Slot | Applied to |
|---|---|
CursorLine |
Absolute and relative columns on the cursor row |
AbsoluteAbove |
Absolute line numbers above the cursor |
AbsoluteBelow |
Absolute line numbers below the cursor |
RelativeAbove |
Relative distances above the cursor |
RelativeBelow |
Relative distances below the cursor |
WrappedLine |
The indicator character on soft-wrapped continuation lines |
All six slots are recommended. Any slot you omit falls through to colorscheme auto-detect or the built-in fallback (Horizon palette).
local themes = require("line-justice").themes
themes.register(spec) -- register (or overwrite) a theme; returns true/false
themes.get("Forest") -- returns the colors table, or nil if not found
themes.list() -- sorted list of all available theme names
themes.exists("Forest") -- true if the name is registered or built-in
-- my-lj-theme.lua (loaded after line-justice.nvim)
local ok, lj = pcall(require, "line-justice")
if not ok then return end
lj.themes.register({
name = "MyTheme",
description = "My personal palette.",
colors = { ... },
})
With lazy.nvim, add dependencies = { "zaakiy/line-justice.nvim" } to ensure load order.
See examples/custom-theme.lua for three fully-annotated example themes.
local lj = require("line-justice")
lj.setup()
require("statuscol").setup({
segments = { { text = { lj.segment }, click = "v:lua.ScLa" } },
})
lj.setup({ wrapped_lines = { indicator = "Arrow" } }) -- ↳
lj.setup({
wrapped_lines = { indicator = "Custom", custom = "⤷" },
})
lj.setup({
line_numbers = {
theme = "Horizon",
overrides = {
WrappedLine = { fg = "#ff9e64", italic = true },
},
},
wrapped_lines = { indicator = "Custom", custom = "╰" },
})
lj.setup({
line_numbers = { theme = nil },
wrapped_lines = { indicator = "Arrow" },
})
Keep all of Horizon's colours but swap the cursor line to a warm orange:
lj.setup({
line_numbers = {
theme = "Horizon",
overrides = {
CursorLine = { fg = "#ff9e64", bold = true },
},
},
})
lj.setup({
line_numbers = {
theme = nil,
overrides = {
CursorLine = { fg = "#FF966C", bold = true },
AbsoluteAbove = { fg = "#565f89" },
AbsoluteBelow = { fg = "#41664f" },
RelativeAbove = { fg = "#7b9ac7" },
RelativeBelow = { fg = "#6aa781" },
WrappedLine = { fg = "#565f89", italic = true },
},
},
wrapped_lines = { indicator = "Custom", custom = "╰" },
})
lj.setup({
line_numbers = {
fade = { enabled = false },
},
})
lj.setup({
line_numbers = {
fade = {
enabled = true,
bands = { near = 3, mid = 8, far = 15 },
},
},
})
lj.setup({
line_numbers = {
fade = {
enabled = true,
relative_strength = 0.90,
absolute_strength = 0.50,
},
},
})
lj.setup({
line_numbers = {
fade = {
enabled = true,
relative_strength = 0.0, -- relative numbers stay full colour
absolute_strength = 0.40, -- absolute numbers fade gently
},
},
})
No code file needs to be so long that its line numbers require a comma. There, it's been said.
And yet — here we are. Maybe it's generated code. Maybe it's a migration file. Maybe it's that one 4,200-line God class that everyone is too afraid to touch. LineJustice doesn't judge. It just makes sure you can still read the gutter.
Absolute line numbers are automatically formatted with thousands-separator commas for any file long enough to need them:
997 3 end
998 2
999 1 -- the calm before the storm
1,000 ← cursor
1,001 1 -- line one thousand and one
1,002 2
The gutter width expands automatically as your file grows — no configuration needed, no truncation, no overlap. A 999-line file uses a 3-character gutter. A 1,000-line file promotes to 5 characters (1,000). A 10,000-line file uses 6 (10,000). It just works.
And if you're pair programming on a file with comma-separated line numbers — well, at least you'll both be able to clearly agree on exactly which line of that monolith you're staring at together.
The gutter width is computed fresh on every render from the total line count:
num_digits = #tostring(total_lines) -- e.g. 4 for a 1000-line file
num_commas = floor((num_digits - 1) / 3) -- e.g. 1 comma in "1,000"
col_w = num_digits + num_commas -- e.g. 5 chars for "1,000"
gutter_w = col_w + 1 + col_w -- abs + space + rel
Both the absolute and relative columns always share the same width, so the gutter is perfectly symmetric regardless of how large the file grows.
| Owned by line-justice | Owned by you (via statuscol) |
|---|---|
| Highlight group registration | statuscol.setup() call |
| Colour theme resolution | Segment placement and ordering |
| Number formatting and rendering | Other segments (signs, folds, etc.) |
| Wrapped-line indicator | relculright, bt_ignore, ft_ignore, … |
vim.o.number / vim.o.relativenumber |
Everything else |