Rice your lock screen.

veiland is a GPU-accelerated Wayland locker composed from layers: wallpaper, particles, clock. Each one is a separate, isolated program. Stack them. Reorder them. Write your own.

Install Browse scenes
deep field scene capture, a procedural nebula and starfield with a moon and a serif clock
aurora card scene capture, a frosted center card over a drifting aurora backdrop
aurora corners scene capture, corner-anchored widgets around a big center clock
raymarcher scene capture, the zero-config default

captures of real locked sessions, recorded with wf-recorder.

config.toml

Scenes are TOML.
Layers are programs.

A scene is a stack of plugins ordered by z_index, like layers in an image editor. Each plugin is its own process rendering into its own GPU buffer; the locker composites them and paints the password pill on top, always last.

Target layers at specific monitors, run a different scene on each output, swap a wallpaper for a shader without touching anything else. If a layer crashes, it disappears. The lock keeps running.

z=2 clock  ·  z=1 sakura petals  ·  z=0 wallpaper
~/.config/veiland/config.toml
[password]
y_percent = 75

[[plugin]]
name    = "wallpaper"
binary  = "veiland-wallpaper"
z_index = 0
[plugin.config]
path = "/home/you/walls/dusk.jpg"

[[plugin]]
name    = "sakura"
binary  = "veiland-sakura"
z_index = 1

[[plugin]]
name     = "clock"
binary   = "veiland-markup"
z_index  = 2
monitors = ["DP-1"]  # this layer, one output only
[plugin.config]
text = "{time:%H:%M}"
gpu

Animated at your refresh rate

Plugins render with their own OpenGL into DMA-BUF buffers the locker samples directly, with zero pixel copies through the CPU. Full-screen animation is the normal case, not a stress test.

isolation

Layers can't touch the lock

Plugins are separate processes behind a deliberately narrow protocol: no message carries a keystroke, and no message maps to "unlock". The API surface is absent, not filtered. Auth stays in the core.

diy

Write yours in an afternoon

A plugin is just a program speaking a documented wire format over a Unix socket. Rust SDK in the box, but any language that can pass a file descriptor works.

dynamic or static

Motion when you want it.
Stillness when you don't.

The same plugin model does both. Run animated backgrounds on the GPU at your refresh rate, or a wallpaper and a few widgets that draw once and sit still. Neither is the "real" mode — pick per scene, or mix them in one.

a dynamic scene: petals falling in real time motion
dynamicparticles, shaders, animated backgrounds

Rendered live on the GPU, every frame, at your monitor's refresh rate. Petals drift, embers rise, a shader churns. Full-screen animation is the normal case, not a stress test.

sakuraembersrainraymarcherblobs
a static scene: a still nebula, clock, and widgets still
statica wallpaper, a clock, glanceable widgets

Draws once and idles — near-zero CPU, calm by design. A blurred wallpaper, a clock, a status cluster, an avatar. The composed, lived-with look, without a single moving pixel.

wallpaper (blur)clockweatheravatarstatus

And they compose: a still card of widgets over a gently drifting background is one config away. Every scene below is somewhere on that line.

plugins

The building blocks, every property documented.

Each plugin draws one kind of layer and reads its own [plugin.config] table. Click a card for its full property list. Every key is optional (omit it, the default applies), colors are 0.0-1.0 float arrays, and the schema belongs to the plugin: the core passes the table through verbatim, so your own plugin defines whatever properties it wants.

backgrounds · opaque, full-region layers for the bottom of the stack
overlays · transparent layers that sit above a background, below text
particles · one idea, six moods; count is absolute, sizes scale with the output
widgets · glanceable info panels and cards; the reference widgets are Python programs
install

Pick your distro, grab a scene, lock.

From the AUR. The package installs the binary and every reference plugin, and registers the PAM service. Nothing manual.

$ yay -S veiland    # or: paru -S veiland

Flake module: binary, plugins, and PAM service in two lines of config.

# flake.nix
inputs.veiland.url = "github:sylflo/veiland";

# configuration.nix
imports = [ inputs.veiland.nixosModules.default ];
services.veiland.enable = true;

Just trying it out? nix run github:sylflo/veiland

Download the .deb from the latest release, built on and for Debian 13. PAM config is bundled.

$ sudo apt install ./veiland_*_amd64.deb

Download the .rpm from the latest release. PAM config is bundled.

$ sudo dnf install ./veiland-*.x86_64.rpm

Linux only. Needs pkg-config, Mesa (libgbm, libEGL, libGLESv2), libdrm, libpam, and a compositor implementing ext-session-lock-v1. A source build does not set up PAM; see PAM setup.

$ cargo build --release
$ nix develop   # optional: dev shell with the full toolchain

Then: a scene in three lines

No config at all renders the built-in raymarched tunnel, the scene in the hero above. To make it yours, start from any gallery scene:

$ mkdir -p ~/.config/veiland
$ cp /usr/share/veiland/examples/sakura.toml ~/.config/veiland/config.toml
$ veiland

Bind that last command to a key, or to hypridle / swayidle.

write a plugin

Your layer, your language, nobody's approval.

A plugin connects over a Unix socket, renders into a GPU buffer, and hands the locker a file descriptor. That's the whole contract, documented in the protocol reference and not tied to Rust. Drop your binary next to your config, point a [[plugin]] block at it, done. No fork, no upstream PR, no rebuild of the locker.

src/main.rs: a complete plugin skeleton
let mut conn = Connection::connect("my-plugin", VERSION)?;
let cfg = conn.wait_for_configure()?.unwrap();
let gbm = GbmEgl::new()?;
let mut dma = DmaBuffer::new(&gbm, cfg.region_w, cfg.region_h)?;
let mut pacer = FramePacer::self_paced();
loop {
    match pacer.next(&mut conn)? {
        Frame::Render => {
            // draw into the DMA-BUF with your own GL…
            conn.submit_frame(&dma, &gbm)?;
            pacer.submitted();
        }
        Frame::Reconfigure(c) => dma.resize_or_keep(&gbm, c.region_w, c.region_h, "my-plugin"),
        Frame::Shutdown => return Ok(()),
    }
}