Skip to content

Cursor Themes

Velle Sinclair edited this page Jul 26, 2026 · 2 revisions

Cursor Themes

Super+Shift+P opens the cursor picker. synui-cursor is the command-line side, and it is the only way to install a theme — the picker selects, it does not install. That is deliberate, not missing: see below.

Super+C is the control panel and Super+Shift+C is cat mode, so P for pointer.


Quick start

synui-cursor list                              # what's installed (name, path)
synui-cursor current                           # active theme and size
synui-cursor install ~/Downloads/some-theme.tar.gz
synui-cursor set Some-Theme 32                 # theme, size in px

The change reaches the running compositor immediately (synctl dispatch cursor_reload). Already-running apps keep the old cursor until they restart — nothing can change that; X11 and GTK clients read the theme once.


Commands

synui-cursor list                    installed themes (name<TAB>path)
synui-cursor current                 active theme<TAB>size
synui-cursor install <archive|dir>   install from .zip/.tar.*/a directory
synui-cursor build <dir>             compile a source tree — runs ITS makefile
synui-cursor set <theme> [size]      make a theme active everywhere

install accepts .zip, .tar.gz, .tar.xz, .tar.bz2, .tar.zst or a directory, and installs into ~/.local/share/icons/<name>. A pack shipping several themes side by side installs all of them.

set refuses a theme that is not installed. Setting a nonexistent one silently gives you the fallback X cursor everywhere with nothing explaining why.


The thing to know: opendesktop ships two shapes that look identical

Cursor themes from opendesktop.org / gnome-look.org come in two forms, and telling them apart is most of what this tool does:

ready-to-use    Theme/cursors/left_ptr, Theme/cursors/watch, …   -> just move it
source tree     makefile, src/*.in, config/*.in                   -> must be compiled

Extracting a source tree into ~/.local/share/icons — the obvious thing to do, and an easy mistake to make — leaves something that is not a cursor theme at all. No cursors/ directory, so nothing will ever see it, and nothing says why.

synui-cursor install detects that case and tells you, instead of silently creating a dud:

synui-cursor: 'some-cursors-3.1a.tar.gz' is a cursor theme SOURCE TREE,
              not a built theme. It has no cursors/ directory yet — it has to be
              compiled first, which usually needs xcursorgen and imagemagick.
              …
                  synui-cursor build ~/.local/share/synui/cursor-src/some-cursors-3.1a
                  synui-cursor install ~/.local/share/synui/cursor-src/some-cursors-3.1a

The extracted tree is staged somewhere permanent before that message prints, because otherwise the command it tells you to run would name a temp directory the tool is about to delete — advice that cannot be followed.

Building needs xorg-xcursorgen and imagemagick.

What counts as a theme

A directory with a cursors/ subdirectory that has something in it.

Deliberately not index.theme: plenty of real themes ship without one, and some source trees ship an index.theme while having no cursors at all — so testing index.theme gets both cases backwards. An empty cursors/ is a build that failed half way, and counts as not-a-theme rather than as a theme whose every cursor is missing.

The same rule is implemented in the C picker and in the shell helper, on purpose, so the two can never disagree about whether something is installed.


Why the picker has no install button

install is safe to expose in a GUI; build is not. Building runs the downloaded archive's own makefile, as you, with your privileges — arbitrary code execution from an untrusted download. So it is never automatic and never a button: it is a separate command that prompts for confirmation first.

If the picker ever grows an install button, build stays CLI-only.


Untrusted archives

These come off the internet, so install treats extraction as hostile.

Path traversal. Nothing is ever used straight out of the extraction directory. Every theme path is re-resolved with realpath and refused if it no longer sits inside that directory — which defeats ../../ traversal ("zip slip") regardless of how the extractor behaves, rather than trusting tar and unzip to reject hostile member names. An archive member called ../../../.config/autostart/x is otherwise a way to write anywhere you can.

Symlinks. Cursor themes legitimately use relative symlinks inside cursors/ (default -> left_ptr, and most of the hash-named aliases), so those must survive — copying them as files would multiply a 1 MB animated cursor across a dozen aliases. But an absolute symlink, or a relative one that climbs out of the theme, is either a mistake or an attempt to get a later read or write redirected. Both are dropped, and dropping one costs only that single alias.

(Sound themes have no legitimate internal symlink, so synui-sound install drops all of them. Same installer shape, different call on that one question.)

Theme names are attacker-controlled. A theme's name is a directory name from the archive, and it goes on to be a shell argument (the picker runs synui-cursor set <name>), a listing row, and an ini value. synui's spawn() runs /bin/sh -c, so a theme called evil;curl …|sh would execute when selected. Two independent layers:

  1. the helper refuses names with shell metacharacters, a leading dash (half the tools downstream would read it as an option), path separators, or control characters — refused outright rather than escaped three different ways;
  2. the picker single-quotes the name anyway.

Theme names are also third-party text on the render path, so they go through draw_clipped (→ news_utf8_trim), never bare cairo_show_text — a name with invalid UTF-8 in it would otherwise poison the cairo context.


A cursor theme has to be written in five places

This is the part that surprises people. Each toolkit has its own idea of where the cursor theme lives, and disagreeing between them is exactly how you end up with one cursor over synui and a different one over an Xwayland game. synui-cursor set writes all five:

# Where Who reads it
1 ~/.config/synui/cursor.state the compositor, at startup and on cursor_reload; the picker writes the same file
2 ~/.config/synui/cursor.env the session wrapper at login (XCURSOR_THEME, XCURSOR_SIZE)
3 ~/.icons/default/index.theme X11 proper — an Xwayland client started without the env vars (a game launched from a .desktop)
4 gsettings and gtk-3.0/settings.ini, gtk-4.0/settings.ini GTK. gsettings reaches running apps live; settings.ini is what an app started outside the session bus reads
5 kcminputrc (kwriteconfig6/5) Qt/KDE — Dolphin and friends read kcminputrc for this, not kdeglobals

Miss #3 and games get the fallback cursor. Miss the settings.ini half of #4 and the change appears to work until the next reboot.

XCURSOR_SIZE must stay pinned

With it unset, libXcursor computes a size from the X screen, and the Xwayland virtual screen spanning several monitors is thousands of pixels wide, so an Xwayland client can end up with a huge pointer while synui's own is 24 px.

Session env vars live in three places: the live /usr/local/bin/synui-session and two blocks in syn-install.sh. The wrapper sources ~/.config/synui/cursor.env after the defaults, so XCURSOR_THEME is no longer hardcoded to Adwaita. The greeter's copy is deliberately left alone — different user, pre-login.


Implementation notes

A theme is named after its own directory — except when an archive ships a bare cursors/ at the top level with nothing wrapping it. Then the theme root is the extraction directory, whose name came from mktemp, and only in that case does the archive's filename supply the name. It still goes through the same validation as any other name.

The installer stops descending the moment a directory is a theme. Archives almost always wrap everything in one top-level directory, so it descends through single-child directories — but a theme with no index.theme has cursors/ as its only child, and descending into that would put the theme "above" the extraction root and trip the escape guard on a perfectly good theme.

Anything that changes cursor settings reaches outside a sandbox. gsettings talks to dconf over the session bus, so it ignores a faked $HOME — a sandboxed test of synui-cursor set will rewrite your real GTK settings unless gsettings and kwriteconfig* are stubbed. See Development Notes.


Before this existed

wlr_xcursor_manager_create(NULL, 24) — theme from the environment, size hardcoded, no setting anywhere. There was no cursor theme configuration at all.


See also: Sound Themes, synui, Keybindings, Commands.

Clone this wiki locally