Repository navigation
Configuration: Global Shader
Biri supports a global post-process shader that runs over the entire composited output before it is displayed. Use it for effects such as colour grading, CRT scanlines, night-light tints, motion-blur trails, or any full-screen visual transformation.
Note
The global shader is available on the TTY/DRM and headless backends from startup.
It has no effect on the winit (nested/X11) backend.
By default, screenshots and screen recordings (screencopy / screencast) render without shader effects. See Shaders in screencast and screenshots to opt in via shaders-in-capture.
Warning
Performance cost — read this before enabling. While a global shader is active, the compositor cannot take any of its normal power-saving shortcuts for that output:
- The entire output is redrawn every frame, continuously, even when nothing on screen changed. There are no partial-damage savings — expect constant GPU load and significantly higher battery drain on a laptop.
- Direct scanout and overlay-plane offload are disabled, so full-screen video / games lose their zero-copy fast path while the shader is on.
- With
reads-cursorset, the hardware cursor is forced to software, adding extra per-frame GPU cost (and it's worse on NVIDIA).
This is inherent to a whole-screen post-process pass, not a tuning problem. Treat a global shader as an always-on GPU effect, and turn it off when you don't need it.
When no global-shader block is present (or enable is not set), there is zero overhead — the compositor renders exactly as it does without the feature.
global-shader {
// Activate the shader.
enable
// Inline GLSL source (mutually exclusive with path).
source "vec4 global_color(vec3 coord) { return tex2D_screen(coord.xy); }"
// Path to a .frag file on disk (mutually exclusive with source).
// path "/home/user/.config/niri/my-shader.frag"
// Shader API flavour: "niri" (default) or "hyprland".
mode "niri"
// Composite the cursor into niri_screen (forces a software cursor).
// reads-cursor
// Redraw scheduling: "auto" (default), "on-damage", or "continuous".
redraw "auto"
// For cursor-local effects: reshade/damage only a box of this radius (logical px)
// around the cursor, leaving the rest of the output scanout-eligible.
// cursor-radius 200
}Fields:
| Field | Type | Default | Description |
|---|---|---|---|
enable |
flag | off | Activate the shader. Without this, the block is parsed but has no effect. |
source |
string | — | Inline GLSL fragment source. Mutually exclusive with path. |
path |
string | — | Path to a .frag file, re-read whenever the config is reloaded. Editing the .frag file alone does not trigger a reload — you must also reload the config (e.g. re-save config.kdl). Mutually exclusive with source. |
mode |
string | "niri" |
API flavour: "niri" or "hyprland". |
reads-cursor |
flag | off | Include cursor pixels in niri_screen. See reads-cursor below. |
redraw |
string | "auto" |
Redraw scheduling. "auto": animate every frame only if the shader uses niri_time or the feedback buffer (niri_prev/tex2D_prev); otherwise redraw only on real damage. "on-damage": never force idle redraws. "continuous": always redraw every frame. See Redraw scheduling below. |
cursor-radius |
integer (px) | — | For cursor-local shaders: reshade and damage only a box of this radius (logical px) around the cursor, preserving direct scanout elsewhere. Omit for a whole-output effect. See cursor-radius below. |
pass |
block (repeatable) | — | One pass in a multi-pass chain. When any pass block is present it replaces the top-level source/path. See Multi-pass chains below. |
If both source and path are set, or if neither is set while enable is present, biri logs a warning and disables the effect — the compositor never crashes.
The shader is hot-reloaded whenever the config is reloaded (e.g. after saving the file while niri watches it). A shader that fails to compile also logs a warning and leaves the screen rendering normally.
In niri mode you supply a single GLSL function:
vec4 global_color(vec3 coord);Biri wraps it with a main() that passes coord.xy (normalised 0..1 UV coordinates across the output) and writes your return value to gl_FragColor.
| Name | Type | Description |
|---|---|---|
niri_time |
float |
Seconds elapsed since the shader was activated. |
niri_size |
vec2 |
Element dimensions in physical pixels. In region mode this is the box size, not the full output — use niri_output_size for absolute-pixel math. |
niri_scale |
float |
Output scale factor. |
niri_cursor |
vec2 |
Cursor position in output-local physical pixels. |
niri_screen |
sampler2D |
The composited frame below the effect. |
niri_prev |
sampler2D |
The previous frame's shader output (effect + screen), via tex2D_prev(uv). |
niri_screen_prev |
sampler2D |
The previous frame's screen (no effect), via tex2D_screen_prev(uv). Use prev − tex2D_screen_prev to recover a feedback trail without scroll-smear. |
niri_buffer |
sampler2D |
The dedicated feedback buffer (last frame), via tex2D_buffer(uv). Equals niri_prev unless you define global_buffer (see below). |
niri_region |
vec4 |
The region this element covers in output-normalised coords (origin.xy, size.xy). (0,0,1,1) for a whole-output shader; a sub-box in region mode. |
niri_output_size |
vec2 |
True full-output size in physical pixels. Equals niri_size for a whole-output shader, but differs in region mode (where niri_size is the box). |
Convenience helpers (already defined in the prelude):
vec4 tex2D_screen(vec2 uv); // samples the screen below at output-normalised uv
vec4 tex2D_prev(vec2 uv); // samples the previous frame's output at output-normalised uv
vec4 tex2D_screen_prev(vec2 uv); // samples the previous frame's screen (no effect)
vec4 tex2D_buffer(vec2 uv); // samples the dedicated feedback buffer (last frame)coord.xy (passed to global_color) and the uv arguments to tex2D_screen/tex2D_prev are always output-normalised (0..1 across the whole output), regardless of region mode.
Region-mode contract (cursor-radius set):
- Use
niri_output_size— notniri_size— for absolute-pixel math (e.g.coord.xy * niri_output_sizeto get physical px).niri_cursoris already in absolute output px. - You may only sample
tex2D_screen/tex2D_prevwithin the region (near the cursor); samples outside the box clamp to a transparent border. - These uniforms exist in
nirimode only.
The GLSL dialect is GLES2 (#version 100).
Use texture2D(), not texture().
Declare varyings with varying, not in/out.
global-shader {
enable
source "vec4 global_color(vec3 coord) {
vec4 c = tex2D_screen(coord.xy);
return vec4(c.r * 1.0, c.g * 0.85, c.b * 0.7, c.a);
}"
}Each frame mixes 5 % of the current screen with 95 % of the previous frame's output.
global-shader {
enable
source "vec4 global_color(vec3 coord) {
vec4 current = tex2D_screen(coord.xy);
vec4 prev = tex2D_prev(coord.xy);
return mix(prev, current, 0.05);
}"
}Uses niri_time to pulse the vignette strength.
global-shader {
enable
source "vec4 global_color(vec3 coord) {
vec4 c = tex2D_screen(coord.xy);
vec2 uv = coord.xy - 0.5;
float d = dot(uv, uv);
float pulse = 0.6 + 0.4 * sin(niri_time * 2.0);
return c * (1.0 - d * pulse * 3.0);
}"
}niri_prev is the previous output — your effect and the screen mixed together — so a trail
recovered from it picks up scrolling/video motion and smears. To avoid that, define an optional
second function that writes a screen-independent feedback buffer:
vec4 global_buffer(vec3 coord); // returns what to store in niri_buffer this frameWhen present, biri renders global_buffer into a clean offscreen texture each frame (reading
last frame's buffer via tex2D_buffer), then your global_color reads this frame's buffer
via tex2D_buffer to composite it over the screen. Because the buffer never contains the screen,
trails don't smear when content scrolls underneath.
global-shader {
enable
source "
vec4 global_buffer(vec3 c){
vec3 prev = tex2D_buffer(c.xy).rgb; // pure trail, no screen
float d = length(c.xy*niri_output_size - niri_cursor);
float fresh = smoothstep(18.0, 0.0, d);
return vec4(max(prev*0.90, vec3(0.2,0.8,1.0)*fresh), 1.0);
}
vec4 global_color(vec3 c){
vec3 s = tex2D_screen(c.xy).rgb;
vec4 b = tex2D_buffer(c.xy); // this frame's trail
return vec4(mix(s, b.rgb, length(b.rgb)*0.6), 1.0);
}"
}Notes: a global_buffer shader is always treated as animated (redraws every frame, whole-output —
no region mode). If you omit global_buffer, niri_buffer simply aliases niri_prev. niri mode
only.
Instead of a single shader, you can run an ordered chain of shaders — e.g. blur → grade →
vignette — where each pass reads the previous pass's output. List the passes as repeatable
pass {} blocks inside global-shader {}. When one or more pass blocks are present they
are the chain, and the top-level source/path are ignored.
global-shader {
enable
pass {
path "/home/user/.config/niri/blur.frag"
}
pass {
source "vec4 global_color(vec3 c){ return tex2D_screen(c.xy); }"
}
pass {
path "/home/user/.config/niri/vignette.frag"
mode "niri"
}
}Each pass is an ordinary global_color shader (or a hyprland-mode raw shader). What it sees:
| Sampler / helper | Meaning for pass N |
|---|---|
niri_screen / tex2D_screen
|
this pass's input = pass N−1's output. Pass 0 = the real composited screen. |
niri_source / tex2D_source
|
the original composited screen, unfiltered — the same for every pass. |
niri_prev / tex2D_prev
|
this pass's own output from last frame (per-pass feedback). |
niri_screen_prev / tex2D_screen_prev
|
the previous frame's real screen (frame-level, all passes). |
niri_buffer / tex2D_buffer + global_buffer
|
this pass's own dedicated accumulator (see above), per pass. |
Because pass 0 reads the real screen as niri_screen (for pass 0, niri_source and niri_screen
both point to the real screen; later passes still see the original via niri_source), with
niri_prev being its own last output, any existing single shader drops into a chain unchanged,
and a chain of one pass behaves exactly like the single-shader form.
The whole-chain flags still live on the outer global-shader {} block: reads-cursor governs
whether cursor pixels appear in niri_screen for the entire chain, and redraw controls the
chain's scheduling.
Per-pass fields:
| Field | Type | Default | Description |
|---|---|---|---|
source |
string | — | Inline GLSL for this pass. Mutually exclusive with path. |
path |
string | — | Path to a .frag file for this pass. Mutually exclusive with source. |
mode |
string | the block's mode
|
API flavour for this pass: "niri" or "hyprland". Lets you mix a hyprland pass into a niri chain. |
Notes: a multi-pass chain (two or more passes) is always whole-output — cursor-radius region
mode applies only to a single-pass effect. The chain is treated as animated if any pass uses
niri_time, feedback (niri_prev/niri_screen_prev), or a global_buffer. If any pass fails to
resolve or compile, the whole chain is disabled and the screen renders normally. A hyprland-mode
pass uses its own prelude and does not see the niri samplers (niri_source, niri_buffer, …).
In hyprland mode you supply a complete raw fragment shader with its own main() that writes gl_FragColor.
This is intended to make Hyprland community screen_shaders work with minimal changes.
The following aliases are pre-defined so that typical Hyprland shaders compile without modification:
| Alias | Resolves to |
|---|---|
tex |
niri_screen (screen sampler) |
v_texcoord |
normalised UV varying |
time |
niri_time |
wl_output |
0 (integer constant) |
Note
niri_cursor is not available in hyprland mode.
Only the GLES2 (#version 100) dialect is supported.
Hyprland shaders written as #version 300 es need the following manual edits before use:
-
texture(sampler, uv)→texture2D(sampler, uv) -
out vec4 fragColor;…fragColor = …→gl_FragColor = … -
in vec2 v_texcoord;→varying vec2 v_texcoord;
global-shader {
enable
path "/home/user/.config/niri/crt.frag"
mode "hyprland"
}By default, the hardware cursor is composited on a separate plane after the shader runs. This means:
- The cursor is not included in
niri_screen, so shaders cannot transform it. -
niri_cursor(position) is still available for cursor-following effects (glow, ripple, etc.). - The hardware cursor plane is used — minimal GPU cost, no cursor lag.
When reads-cursor is set:
- Biri composites the cursor into the screen texture before running the shader, so
niri_screencontains the actual cursor pixels. - This forces a software cursor for that output, which carries a measurable GPU and battery cost (especially on Nvidia).
Use reads-cursor only when your shader needs to transform the cursor pixels themselves (e.g. colour-inverting the cursor region).
global-shader {
enable
source "vec4 global_color(vec3 coord) {
return tex2D_screen(coord.xy);
}"
reads-cursor
}Any change to the global-shader block takes effect the next time niri reloads its config. When using path, the file is re-read on config reload only — editing the .frag file by itself does not trigger a reload, so re-save your config.kdl (or otherwise reload the config) to pick up shader-file edits.
If the new shader fails to compile, the old shader (if any) continues running and a warning is logged — the compositor never crashes.
By default (redraw "auto") biri scans your shader source to decide how often to redraw:
- If it references
niri_timeor the feedback buffer (niri_prev/tex2D_prev), the effect is animated, so biri redraws every frame — it keeps animating even when the desktop is completely idle. - Otherwise the effect is a pure function of the screen below, so biri redraws only when something actually changes (no wasted GPU when idle).
Override this with redraw:
| Value | Behaviour |
|---|---|
"auto" (default) |
Animate every frame iff the shader uses niri_time/niri_prev/tex2D_prev. |
"on-damage" |
Never force idle redraws — animate only when other on-screen activity triggers a frame. |
"continuous" |
Always redraw every frame, even for a static shader. |
The same auto scan drives region and per-window shaders, which have no redraw setting of their own. To limit how fast any of them animate, see shader-animation-max-fps.
The scan is a literal text match, so it errs on the side of more redraws (e.g. an identifier like lifetime in a hyprland-mode shader counts as using time). Use redraw "on-damage" to opt out if that happens.
global-shader {
enable
source "vec4 global_color(vec3 c) { return tex2D_screen(c.xy); }"
cursor-radius 200
redraw "auto"
}A top-level setting that caps how often shader-driven redraws happen, across all shader scopes (global, region, and per-window):
shader-animation-max-fps 30Without it (or with 0) an animated shader recomposites at the output's native refresh rate — 144 recomposites/sec on a 144 Hz panel, even when the desktop is otherwise idle. With a cap set, biri renders one shader frame and then schedules the next from a one-shot timer instead of the vblank loop, so an idle animated shader runs at roughly the configured rate.
The cap applies only when shaders are the sole reason to redraw. Shaders riding along with a real animation — a window drag, a workspace switch, the overview — are never throttled, so nothing else gets choppy.
For effects that only touch the area around the cursor (a ring, spotlight, magnifier), set cursor-radius to the effect's radius in logical pixels. Biri then reshades and damages only a box of that size around the cursor instead of the whole output, so the rest of the screen keeps direct scanout / overlay-plane offload and the GPU cost drops sharply.
Region mode applies only when the shader uses niri_cursor and is not animated (no niri_time / feedback) — an animated whole-screen effect still reshades the whole output. Because it keys off niri_cursor, region mode is niri mode only; hyprland-mode shaders (which have no cursor uniform) always render whole-output and cursor-radius is ignored for them. The coordinate contract for region-mode shaders is documented under niri Mode.
| Condition | Overhead |
|---|---|
No global-shader block |
Zero |
global-shader block present, enable absent |
Zero |
enable set, valid shader |
Full-frame redraw every frame; no direct scanout; overlay planes disabled |
reads-cursor additionally set |
As above, plus software cursor (extra GPU cost) |
A region-shader {} block runs a post-process shader scoped to a fixed screen rectangle instead of the whole output. It is independent of global-shader — you can have both active simultaneously, and you can repeat region-shader {} as many times as you need (they draw in config order).
region-shader {
geometry x=100 y=100 width=800 height=600
source "vec4 global_color(vec3 c){ vec3 s=tex2D_screen(c.xy).rgb; return vec4(s.bgr,1.0); }"
}| Field | Type | Default | Description |
|---|---|---|---|
geometry |
node | — | Defines the rectangle. All four properties x, y, width, height are logical pixels (pre-scale). |
output |
string | — | Optional output name (e.g. "DP-1"). When set, the region is only applied on that output. When absent, the same rectangle is applied on every output. |
source |
string | — | Inline GLSL source. Same global_color contract as global-shader. Mutually exclusive with path. |
path |
string | — | Path to a .frag file. Mutually exclusive with source. |
mode |
string | "niri" |
API flavour: "niri" or "hyprland". |
pass |
block (repeatable) | — | Multi-pass chain — same syntax as global-shader. When any pass blocks are present, the top-level source/path are ignored. See Multi-pass chains. |
A region shader uses the same global_color function and uniforms as global-shader — see niri Mode for the full uniform table. The key difference is scope:
-
niri_screen(andtex2D_screen) samples the composited pixels inside the region rectangle only. Samples outside the rectangle clamp to a transparent border. -
niri_sizeis the region rectangle's dimensions in physical pixels (not the full output). Useniri_output_sizefor absolute-pixel math when needed. -
niri_regionis(origin.xy, size.xy)in output-normalised coords for this region box. -
coord.xypassed toglobal_colorand theuvarguments totex2D_screenare output-normalised (0..1 across the whole output), as withglobal-shader.
Multi-pass chains work the same way as for global-shader — each pass reads the previous pass's output as niri_screen, and niri_source always holds the original composited pixels for the region.
-
No per-scope feedback.
niri_prev,niri_screen_prev, andniri_bufferall aliasniri_screen(the current composited pixels in the rect), andglobal_bufferis ignored. There is no previous-frame feedback within a region shader in this release. -
No
reads-cursororredrawcontrols. Redraw is automatic: a region shader redraws when the composited content under its rectangle changes, and — if it usesniri_time— every frame (the sameautobehaviour asglobal-shader, with the same continuous-GPU-load cost). - No overlap compositing. Overlapping regions draw independently in config order; no blending between them is performed.
region-shader {
geometry x=0 y=0 width=960 height=540
output "HDMI-A-1"
source "vec4 global_color(vec3 c){ vec3 s=tex2D_screen(c.xy).rgb; return vec4(s.bgr,1.0); }"
}Multiple region-shader blocks are independent. They compile separately if their sources differ (only one compiled shader is kept per unique source).
region-shader {
geometry x=0 y=0 width=1920 height=40
source "vec4 global_color(vec3 c){ vec4 s=tex2D_screen(c.xy); return vec4(s.r*1.0,s.g*0.5,s.b*0.5,s.a); }"
}
region-shader {
geometry x=0 y=1040 width=1920 height=40
source "vec4 global_color(vec3 c){ vec4 s=tex2D_screen(c.xy); return vec4(s.r*0.5,s.g*1.0,s.b*0.5,s.a); }"
}region-shader {
geometry x=200 y=200 width=400 height=300
pass {
source "vec4 global_color(vec3 c){ return tex2D_screen(c.xy); }"
}
pass {
source "vec4 global_color(vec3 c){ vec4 s=tex2D_screen(c.xy); return vec4(1.0-s.r,1.0-s.g,1.0-s.b,s.a); }"
}
}A shader {} block inside a window-rule {} applies a post-process shader to a single window's content.
The effect is independent of global-shader and region-shader — all three can be active at the same time.
window-rule {
match app-id="Alacritty"
shader {
source "vec4 global_color(vec3 c){ vec3 s=tex2D_screen(c.xy).rgb; float g=dot(s,vec3(0.299,0.587,0.114)); return vec4(vec3(g),1.0); }"
}
}The shader {} child accepts the same source fields as region-shader:
| Field | Type | Default | Description |
|---|---|---|---|
source |
string | — | Inline GLSL source. Same global_color contract as global-shader. Mutually exclusive with path. |
path |
string | — | Path to a .frag file. Mutually exclusive with source. |
mode |
string | "niri" |
API flavour: "niri" or "hyprland". |
pass |
block (repeatable) | — | Multi-pass chain — same syntax as global-shader. When any pass blocks are present, the top-level source/path are ignored. See Multi-pass chains. |
A window shader uses the same global_color function and uniforms as global-shader and region-shader — see niri Mode for the full uniform table. The key scoping differences:
-
niri_screen(andtex2D_screen) samples the window's own content only. -
niri_sizeis the window's physical size in pixels. -
coord.xypassed toglobal_coloris 0..1 across the window. -
niri_regionis always(0,0,1,1)for a window shader (the shader covers the whole window). - Border, shadow, and focus-ring decorations are rendered outside the shader, unaffected.
Multiple windows with a shader {} rule are independent: each is shaded separately, and windows that share an identical shader source share a compiled program (deduplication is automatic).
Multi-pass chains work the same way as for global-shader — each pass reads the previous pass's output as niri_screen, and niri_source holds the original window content.
Window shaders animate: niri_time is the compositor's shared shader clock (seconds since startup), the same origin used by global-shader and region-shader, so several shaded windows stay in phase with each other and with a global shader.
Redraw scheduling is automatic — there is no per-window redraw setting:
- A window shader whose source references
niri_timekeeps the output redrawing every frame while that window is on screen, exactly like an animatedglobal-shader(with the same continuous-GPU-load cost). - A shader that doesn't reference
niri_timeimposes no continuous-redraw cost; it re-runs when the window's own content is damaged. - Only visible animating windows force redraws. A shaded window on an inactive workspace, or scrolled off the viewport, does not keep the GPU busy. While the overview is open, all windows on the output are treated as visible, since the overview shows several workspaces at once.
Use shader-animation-max-fps to cap the rate of shader-driven redraws.
-
No cursor reactivity.
niri_cursoris always(0.0, 0.0)for window shaders. Do not write window shaders that depend on it; useglobal-shaderorregion-shaderfor cursor-driven effects. -
niri_timeis live on the display, but0.0in a few paths. Window shaders animate normally on screen (see Animation above), butniri_timeis fixed at0.0in single-window screencasts (therender_windows_for_screen_castpath has no access to the compositor time origin) and in unmap/close snapshots. Full-output captures use the live clock. -
No per-scope feedback.
niri_prev,niri_screen_prev, andniri_bufferall aliasniri_screen(the current window content), andglobal_bufferis ignored. There is no previous-frame feedback within a window shader in this release. -
Corner-rounding is not applied inside the shader. The shaded result is drawn over the window's full rectangle, so a window with
corner-radiusconfigured will appear with square corners while a window shader is active — the rounded clipping that normally happens at composite time does not apply to the shaded result. - Content only. The shader cannot see or affect border, shadow, focus-ring, or any other decoration — those are composited around the result after the shader runs.
-
No
reads-cursororredrawcontrols. Redraw is automatic, as described under Animation above.
window-rule {
match app-id="Alacritty"
shader {
source "vec4 global_color(vec3 c){ vec3 s=tex2D_screen(c.xy).rgb; float g=dot(s,vec3(0.299,0.587,0.114)); return vec4(vec3(g),1.0); }"
}
}window-rule {
match app-id="firefox"
shader {
source "vec4 global_color(vec3 c){ vec4 s=tex2D_screen(c.xy); return vec4(1.0-s.r,1.0-s.g,1.0-s.b,s.a); }"
}
}window-rule {
match app-id="code"
shader {
pass {
source "vec4 global_color(vec3 c){ return tex2D_screen(c.xy); }"
}
pass {
source "vec4 global_color(vec3 c){ vec4 s=tex2D_screen(c.xy); float g=dot(s.rgb,vec3(0.299,0.587,0.114)); return vec4(vec3(g)*1.2,s.a); }"
}
}
}A top-level window-shaders {} block defines named shader presets that can be applied to any window at runtime, without editing window rules:
window-shaders {
preset "grayscale" {
source "vec4 global_color(vec3 c){ vec3 s=tex2D_screen(c.xy).rgb; float g=dot(s,vec3(0.299,0.587,0.114)); return vec4(vec3(g),1.0); }"
}
preset "fire" {
path "~/.config/niri/shaders/fire.frag"
}
}Each preset takes a name argument followed by the same source fields as a window-rule shader {} block (source, path, mode, pass). Presets are compiled at config load, and repeated window-shaders {} blocks accumulate in order.
Two bindable actions drive them (both also available as niri msg action ... [--id <window-id>]):
-
toggle-window-shader— turns the focused window's shader off and back on. Works on the window-rule shader as well as a selected preset. -
cycle-window-shader— rotates the focused window through: default (the window-rule shader, or none) → first preset → … → last preset → back to default. Cycling always re-enables a shader that was toggled off.
The selection is per-window runtime state: it survives config reloads (presets are re-resolved by name) but is not persisted across compositor restarts. A preset whose name has disappeared from the config falls back to rendering no shader, and the next cycle-window-shader restarts at the first preset.
A shader {} block inside an output {} block applies a post-process shader to that single output, without writing any GLSL. It takes the same source fields as region-shader (source, path, mode, pass), minus geometry — an output shader always covers the whole output it's attached to.
output "eDP-1" {
shader {
preset "saturation" amount=1.4
}
}
output "DP-2" {
shader {
preset "temperature" kelvin=4000
}
}Output matching for shader {} goes through the same make/model/serial-or-connector matching as the rest of the output block.
Instead of hand-written GLSL, shader {} (and pass {} inside it) can name a built-in colour filter with preset:
| Preset | Property | Default | Notes |
|---|---|---|---|
grayscale |
— | — | Converts to luma. |
invert |
— | — | Inverts each colour channel. |
saturation |
amount= |
1.5 |
0 is full grayscale, 1 is unchanged, values above 1 boost saturation. |
temperature |
kelvin= |
4000 |
Warms (low values) or cools (high values) the image. kelvin=6500 is exactly neutral — identity, no tint. |
These generated shaders are static: they contain no animation uniforms, so — unlike a hand-written shader that reads niri_time — they cost no continuous redraws. An output shader built entirely from built-in presets only redraws when the output's own content changes, making it safe to leave on permanently as an accessibility filter (e.g. an always-on grayscale or inverted-colour output).
A top-level output-shaders {} block defines named shader presets that any output's shader {} can select by name, and that cycle-output-shader walks through at runtime:
output-shaders {
preset "night" {
preset "temperature" kelvin=3200
}
preset "mono" {
preset "grayscale"
}
preset "warm-mono" {
pass {
preset "grayscale"
}
pass {
preset "temperature" kelvin=3500
}
}
}
output "HDMI-A-1" {
shader {
preset "warm-mono"
}
}The preset field resolves differently depending on where it appears:
- A
presetat the top level of an output's ownshader {}block — i.e. directly insideoutput { shader { … } }— resolves against youroutput-shaderspresets first, then falls back to the built-in table. This means a user preset shadows a built-in preset of the same name. - A
presetanywhere inside anoutput-shaders { preset "name" { … } }body — whether at that body's top level or nested inside one of itspass {}blocks — resolves against the built-in table only. It can never name another user preset.
That second rule is what makes preset recursion structurally impossible: a user preset's body — top level or pass-nested — can only ever resolve to a built-in. There is no path by which a preset can (directly or transitively) refer back to itself or to another user preset.
When more than one shader effect is active on the same output, they compose in a fixed order, from the viewer's perspective:
global shader → output shader → region shaders → windows
The global shader (if any) is applied over everything, including the output shader. The output shader is applied over any region shaders on that output, which are themselves applied over the windows underneath. In practice this means a grayscale (or otherwise colour-filtered) output shader will also gray out any region shader effects on that same output — the output shader operates on the fully composited scene below it, region shaders included.
The order cuts the other way for the global shader, which is worth stating plainly because it surprises people: an output shader does not filter the global shader. The global shader draws on top of the output shader's result, so its output keeps its own colours. A cursor-local global shader is the case where this is most visible — set preset "grayscale" on an output while a rainbow cursor-tunnel global shader is enabled, and everything greys except the rainbow around the pointer.
Two bindable actions control the output shader (both also available as niri msg action …):
-
toggle-output-shader— turns the output's shader off and back on. Takes an optional output-name argument; without one, it acts on the focused output. -
cycle-output-shader— rotates the output through: default (theoutput { shader {} }shader, or none) → firstoutput-shaderspreset → … → last preset → back to default. Cycling always re-enables a shader that was turned off withtoggle-output-shader.
binds {
Mod+Shift+G {
toggle-output-shader;
}
Mod+Shift+H {
toggle-output-shader "eDP-1";
}
Mod+Shift+C {
cycle-output-shader;
}
}Or from the command line:
niri msg action toggle-output-shader --output DP-2
See toggle-output-shader and cycle-output-shader in Key Bindings for the full action reference.
-
The cursor is not filtered. The output shader element is pushed below the pointer, and on the TTY backend the cursor is usually a hardware plane outside the captured framebuffer. So
preset "invert"orpreset "grayscale"leaves the mouse pointer un-inverted / un-grayed, even though the rest of the output is filtered. If you're setting this up for accessibility (e.g. an inverted-colour or grayscale output), be aware the pointer will not follow. The global shader hasreads-cursorfor this; the output shader has no equivalent. -
A global shader is not filtered by an output shader. The compositing order puts the global shader above the output shader (see Compositing order), so if you set
preset "grayscale"orpreset "invert"on an output for accessibility while aglobal-shaderis also enabled, the global shader's output keeps its original colours. If you want the whole screen filtered, either disable the global shader or apply the filter as the last pass of the global shader itself rather than as an output shader. -
A
path-sourced shader needs a config touch to reload. As with region shaders, the shader chain is re-read from disk on render, but shader programs are only compiled on config reload. Editing a.fragfile referenced bypathwithout touching the config leaves the render-time cache key pointing at no compiled program, and the shader silently stops drawing. After editing a shader file on disk, touch or re-save your config file to trigger a reload.
By default, shader effects — global, region, and per-window — do not appear in portal screencasts (Google Meet, OBS, browser screen-share, Zoom), grim screenshots, or wl-screenrec recordings. This keeps shaders as a local display effect and prevents them from leaking into shared or recorded content.
To opt in, add the bare shaders-in-capture flag at the top level of your config:
shaders-in-captureWhen the flag is present, the global shader, all region shaders, and all per-window shaders appear in portal screencast, screencopy, and ext-image-copy-capture captures (grim, wl-screenrec, wayvnc, wl-mirror). The live display is unaffected either way.
KMS capture is always independent of this flag. Direct KMS capture tools such as gpu-screen-recorder -w <connector> read the raw scanout buffer after all post-processing has been applied, so they always show shader output regardless of whether shaders-in-capture is set.
Note
Behavior change. Before shaders-in-capture was introduced, per-window shaders always appeared in screencopy captures. With the flag absent (the default), per-window shaders are not included in captures. Add shaders-in-capture to restore that behavior — and to also include global and region shaders.
-
TTY/DRM and headless. The effect is available on both backends from startup. It is intentionally not applied on the nested winit backend (running niri in a window). By default it is also not applied to screenshots or screen recordings — use
shaders-in-captureto opt in, including streams from headless outputs. -
Output transform. The effect is verified on outputs with the default (
normal) transform. On outputs configured with a non-defaulttransform(e.g.90,270,flipped), the shader's view ofniri_screen/niri_prevmay be mis-oriented. If you use a rotated or flipped output, verify your shader there before relying on it. -
Whole-output
global-shader. Theglobal-shaderblock applies to all outputs; per-layer global shaders are not supported. For a shader scoped to one output, use a per-output shader (output { shader {} }); for sub-output scoping, use a region shader (a screen rectangle) or a per-window shader (window-rule { shader {} }). Multi-pass chains are supported (see Multi-pass chains) but a chain of two or more passes is always whole-output (nocursor-radiusregion mode).
- Getting Started
- Example systemd Setup
- Important Software
- Workspaces
- Floating Windows
- Tabs
- Overview
- Screencasting
- Layer‐Shell Components
- IPC,
niri msg - Application-Specific Issues
- Nvidia
- Xwayland
- Gestures
- Fullscreen and Maximize
- Window Effects
- Packaging niri
- Integrating niri
- Security Model
- Accessibility
- Name and Logo
- FAQ