Theme
Themes live in their own files, one per theme, under ~/.config/moonswing/themes/. config.toml picks one by name — the file’s basename without .toml:
theme = "dracula"Five themes ship with the shell and are written into that directory the first time it starts:
| Name | Looks like |
|---|---|
glassy |
Cool translucent surfaces that let the wallpaper through. The default. |
forest |
Pine and moss over a near-black green, floating on a lit sage rim. |
dracula |
The canonical Dracula palette. |
midnight |
Indigo over deep water, one type size up, and lit: its cards glow rather than casting a shadow. |
carbon |
Machined graphite. Flat, square, unlifted — and every bar menu grows out of the bar on a flared join. |
If theme is absent, names a theme that does not exist, or names a file that will not parse, the shell falls back to glassy rather than starting unstyled. A single bad value inside a theme file costs only that key.
The Appearance page in Settings → Shell is the easy way in: it lists every theme with a preview of its colors, switches on click with no restart, and offers New theme…. The five shipped themes are read-only there — editing one offers to duplicate it first.
Because the shell owns those five files, it rewrites any of them that differs from what it ships every time it starts, so a fix to a shipped palette reaches you on the next launch. Editing dracula.toml by hand will not stick; duplicate it and edit the copy. Your own theme files are never touched.
Writing a theme file
Section titled “Writing a theme file”A theme file is a flat table — no section header. Every key is optional.
name = "Gruvbox"
font = "Ubuntu Sans"font_size = 13.0blur = 24.0
accent = "#458588"foreground = "#EBDBB2"surface_hover = "#504945"surface_pressed = "#3C3836"workspace_background = "#3C3836"popup_background = "#282828"popup_foreground = "#EBDBB2"control_surface = "#504945"slider_track = "#504945"muted = "#928374"divider = "#33EBDBB2"scrim = "#88282828"
panel_background = "#EE282828"panel_gradient = truepanel_margin = 0panel_radius = 0.0panel_border = "#33EBDBB2"panel_border_width = 0.0
popup_radius = 8.0popup_gap = 0.0popup_attach_radius = 0.0popup_border = "#33EBDBB2"popup_border_width = 1.0
popup_shadow_color = "#66000000"popup_shadow_blur = 16.0popup_shadow_spread = 0.0popup_shadow_offset_x = 0.0popup_shadow_offset_y = 6.0
popup_animation = "slide"popup_animation_duration = 140
overlay_animation = "scale"overlay_animation_duration = 160overlay_animation_exit_ratio = 1.0overlay_animation_curve = "ease_out"Colors are hex strings in #RRGGBB (opaque) or #AARRGGBB (with alpha, where AA is the alpha channel). "#33FFFFFF" is white at ~20% opacity. Alpha is what makes a translucent theme translucent: panel and popup surfaces composite against the desktop behind them.
| Key | Default | Description |
|---|---|---|
name |
the filename | Display name shown in the settings picker |
font |
Ubuntu Sans |
Font family used for all text across panels and popups. Any fontconfig family name; the settings picker lists the ones installed (via fc-list), and falls back to a free-typed field where there is no fontconfig |
font_size |
13.0 |
Size of the shell’s body text, in logical pixels, and with it the whole type scale — labels, captions and headings keep their proportions either side of it. Clamped to 6–32. See the note below |
blur |
24.0 |
Blur applied behind the settings and launcher overlays (see the note below) |
accent |
#853953 |
Focused workspace button, slider fill, selection highlights, chart series (see the note below) |
foreground |
#F3F4F4 |
Primary text and icon color in the panels |
surface_hover |
#853953 |
Button background when hovered |
surface_pressed |
#612D53 |
Button background when pressed; the mid stop of the panel gradient |
workspace_background |
#2C2C2C |
Unfocused workspace button; the dark end of the panel gradient |
popup_background |
#2C2C2C |
Background of popups, flyouts, menus, the OSD card and the overlay panel |
popup_foreground |
#F3F4F4 |
Text and icons inside popups |
control_surface |
#39393D |
Cards, inputs and tiles inside popups and the settings pages |
slider_track |
#612D53 |
The unfilled portion of sliders and usage bars |
muted |
#C4A8B2 |
Secondary, de-emphasised text: menu headers, timestamps, units, greyed rows |
divider |
#33F3F4F4 |
Separator lines, and the resting fill of subtle list rows (supports alpha) |
notification_badge |
#F2B441 |
What an unread notification is announced in (see the note below) |
notification_badge_foreground |
#2C1218 |
Text and glyphs drawn on notification_badge |
panel_background |
#EE2C2C2C |
The bar’s background, and the opacity of the whole bar (see below) |
panel_gradient |
true |
Whether the bar fades from accent, or is a flat panel_background |
panel_margin |
0 |
Pixels between each bar and the screen edges it is anchored to |
panel_radius |
0.0 |
Corner rounding of each bar (see below for which corners) |
panel_border |
#33F3F4F4 |
The bar’s rim color; drawn only when panel_border_width is above zero |
panel_border_width |
0.0 |
The bar’s rim thickness, or 0 for no rim |
popup_radius |
8.0 |
Corner rounding of popups, menus, flyouts and the OSD card |
popup_gap |
0.0 |
Pixels between a bar popup and the bar; 0 attaches it to the bar |
popup_attach_radius |
0.0 |
How far an attached popup flares outward into the bar; read only at gap 0 |
popup_border |
#33F3F4F4 |
Their rim color; drawn only when popup_border_width is above zero |
popup_border_width |
1.0 |
Their rim thickness, or 0 for no rim |
popup_shadow_color |
#66000000 |
Their shadow’s color; an alpha of 0 turns the shadow off entirely |
popup_shadow_blur |
16.0 |
How far the shadow’s falloff reaches past the card (CSS’s blur radius) |
popup_shadow_spread |
0.0 |
How far the shadow’s shape is grown before blurring; negative shrinks it |
popup_shadow_offset_x |
0.0 |
Horizontal displacement; positive is right, negative is left |
popup_shadow_offset_y |
6.0 |
Vertical displacement; positive is down, negative is up |
popup_animation |
slide |
How a popup arrives, and — reversed — how it leaves (see below) |
popup_animation_duration |
140 |
How long that entrance runs, in milliseconds; the exit takes four fifths of it |
overlay_animation |
scale |
How a full-screen overlay arrives, and — reversed — how it leaves (see below) |
overlay_animation_duration |
160 |
How long that entrance runs, in milliseconds |
overlay_animation_exit_ratio |
1.0 |
What fraction of the entrance the overlay’s exit takes |
overlay_animation_curve |
ease_out |
The easing the overlay’s animation is paced on |
scrim |
#882C2C2C |
The wash drawn over the screen behind a full-screen overlay |
Note that divider is used both as a hairline and as a background fill for quiet rows, so it wants enough alpha to read as a surface.
accent is a fill, and the shell derives its own reading colour from it. The accent is picked to be filled — a primary button, the focused workspace, the travelled half of a slider — which means it has to be dark enough to carry white. A colour dark enough for that is rarely light enough to be read as a label on a dark card: the shell’s own #853953 holds white at 7.8:1 and then lands on its own popup at 1.8:1. So anything writing in the accent — a checked menu row, the current tab, today’s date, a section heading in the settings pages — is drawn in the accent moved along its own lightness ramp until it clears 4.5:1 against popup_background, keeping its hue and saturation. There is no key for this and nothing to set: an accent that already reads as text is used exactly as written, and one that does not is lifted only as far as it has to be. Fills, rims and washes are never touched, so the colour you chose is still the colour that gets filled.
notification_badge is deliberately not accent. The accent is the bar’s ordinary “this one is active” — it is on the focused workspace, on every hovered button and on half the controls in the settings panel — and the floating card that plants itself in the corner of every output would read as more of the same furniture wearing it. This is the one colour in the shell that is allowed to be louder than the rest of the palette, so every shipped theme spells it as a hue that theme does not otherwise use. It is worn by the floating card, the bell’s unread count and the dot on an unread message, so those three cannot disagree about what “unread” looks like.
The panel
Section titled “The panel”The bar has its own color, panel_background, and its alpha is used exactly as written — this is the surface that sits directly on the desktop, so it is the one that decides how much of your wallpaper shows through.
panel_gradient decides its shape:
false— a flat sheet ofpanel_background. This is whatglassydoes.true(the default) — a fade fromaccentat the bar’s own edge, throughsurface_pressed, topanel_background. The bright end sits against the anchored edge: left-aligned on a top bar, top-aligned on a left bar, and so on.
A translucent bar shows the wallpaper the shell itself draws ([background]). Over a bare desktop with no [background] section there is nothing behind the bar but whatever your compositor paints, which may simply be black.
In gradient mode every stop is drawn at panel_background’s alpha, not its own. The bar has exactly one opacity, so setting panel_background = "#402C2C2C" makes the whole bar 25% opaque without having to give accent and surface_pressed a matching alpha — and no stop can end up more opaque than the rest and read as a band across the middle.
Floating the bar
Section titled “Floating the bar”panel_margin moves each bar away from the screen edges it is anchored to, turning a full-bleed strip into a floating pane. This is what glassy does, at 8.
The gap is real, not painted: the bar’s surface genuinely shrinks, so windows will not tile into it and clicks that land in the gap reach the desktop rather than being swallowed by an invisible part of the bar. The space the bar reserves grows with the margin automatically — a 32px bar with an 8px margin keeps maximized windows 40px clear of the screen edge.
panel_radius rounds the bar’s corners, and which corners depends on whether it floats:
panel_marginabove zero — all four corners round, because the bar is a card sitting on the wallpaper.panel_margin = 0— only the two corners facing the middle of the screen round. A flush bar with rounded outer corners would cut wallpaper wedges out of the display’s own corners, which reads as misalignment rather than styling.
panel_border and panel_border_width draw a rim around the bar. Width is the switch, not alpha: leave panel_border_width = 0 and no rim is drawn whatever the color says. A 1px rim is usually what a translucent bar wants — without one a glass bar has no visible boundary over a busy wallpaper.
All three follow a theme switch live; no restart is needed.
Popups
Section titled “Popups”popup_radius, popup_border and popup_border_width are the bar’s three shape keys again, for everything that floats over it: popups, context menus, category flyouts, the tray menus, the app chooser, the confirmation dialogs and the on-screen indicator. They read exactly like their panel_ counterparts — width is the switch for the rim, and a translucent card wants one for the same reason a translucent bar does.
The one difference is corners, and it is the same rule the bar has for the same reason. A popup that floats rounds all four: nothing sits behind it to cut a wedge out of. A popup attached to the bar — see below — squares off the two corners on the join, because the bar does sit behind those, and flares them outward into it when popup_attach_radius is above zero.
A popup opened from the bar is anchored to the bar’s inner edge, centred on the button that opened it. Not to the button itself: modules carry different amounts of padding, so anchoring to the button put each module’s popup at a slightly different height and left all of them overlapping the bar by a pixel or two.
popup_gap is how far off that edge the card sits, and it is the popup analogue of panel_margin one layer up — that floats the bar off the screen, this floats a popup off the bar.
A gap of 0 is the attached mode, not merely a small gap. The card goes flush against the bar, the two corners touching it are squared off, and the rim and the shadow on that edge are dropped, so the popup reads as growing out of the bar rather than floating over it. There is no separate switch; this key is it.
popup_attach_radius then shapes that join, and it is the inverse of the corner radius its name suggests. A rounded corner curves away from the surface behind it, which leaves a transparent wedge on either side of the join and makes the card read as resting against the bar. This sweeps each side outward instead as it reaches the panel, so the card is at its widest exactly where the two meet — the way a branch runs into a trunk. At its default of 0 there is no flare at all and the join is a square butt joint, the card’s sides continuing the bar’s. It is unread at any other gap, because with a gap there is no join to shape.
The flare is drawn outside the card’s own box, so the shell grows the popup’s window to make room for it exactly as it does for the shadow, and takes the larger of the two rather than the sum.
carbon is the shipped example of the pair: popup_gap = 0 with popup_attach_radius = 12, on an opaque bar with no shadow anywhere in the theme, so the flared join is the only shaping in the picture and every menu reads as an extension of the panel rather than a card in front of it.
Two things to know before attaching a theme. A theme with a translucent popup_background should keep a gap: at zero the popup’s fill and the bar’s composite separately against the wallpaper, so the join shows a step in tone that nothing here can remove, and a flare only makes that step wider — which is why glassy sets popup_gap = 8 to match its own panel_margin rather than attaching. And a bar with panel_border_width above zero draws its rim on the inner edge too — the edge the menus come out of. The compositor places a popup below the bar rather than over it, so the card cannot paint that hairline out; the bar leaves it out instead, across exactly the stretch the open menu covers. carbon is the shipped example: a 1px rim in the same colour and width as its popups’, so the outline runs along the bar, breaks off where a menu is open, goes round the card and comes back.
Hover tooltips take the edge anchor but never attach: a label that comes and goes with the pointer reads as a floating card, not as part of the furniture. Menus anchored to the pointer — the desktop’s context menu, the app-directory’s category flyouts — have no panel edge to sit off and are unaffected by either key.
Sensible defaults are shipped rather than zero — 8.0 with a 1px rim — because that is the shape the shell’s menus have always drawn. A theme that says nothing about popups gets that, including a theme file written before these keys existed.
The five popup_shadow_* keys are a CSS box-shadow, spelled out: a color, a blur radius, a spread, and an offset on each axis. There is one shadow per theme rather than the stack CSS allows.
A shadow makes a popup’s window bigger. A popup is its own compositor surface, sized to its content, and a shadow paints outside the card — so the shell grows the surface by the shadow’s reach (blur + spread, shifted by the offset, on each side independently) and then repositions the popup by that same amount, so the card lands exactly where it would have without one. Two consequences worth knowing: a click landing in the shadow’s margin hits the popup rather than passing through to what is underneath, and a very large blur on a popup near a screen edge gives the compositor more to slide back on-screen. Setting popup_shadow_color’s alpha to 0 removes the margin along with the shadow, restoring the exact geometry of a shell with no shadow at all — which is what carbon does.
Nothing says the shadow has to read as one. Give it a colour off the palette rather than a black, leave both offsets at 0 so it is not displaced from the card, and take popup_shadow_spread above zero so the falloff starts outside the card’s edge, and the same key paints a symmetrical bloom around the card instead of weight beneath it. That is midnight, and the geometry follows it: with no displacement the window grows by blur + spread on all four sides equally, and on a bar popup the joined side is still clamped to popup_gap, so the glow fills the gap between card and bar and stops at the panel.
A bar popup never paints its shadow over the bar: on the joined edge the margin is clamped to popup_gap, so at a small gap the shadow fills it and stops, and at 0 the surface is flush and the shadow is cut exactly at the join. A gap of popup_shadow_blur + popup_shadow_spread or more leaves the shadow untouched.
Popup sizes are not themable. Each module fixes its own width, and some of them fix it deliberately: the sound popup pins its width because a popup that resizes after it has been placed walks away from the button that opened it.
About popup_animation
Section titled “About popup_animation”popup_animation is how a popup arrives. Whichever effect you pick is also how it leaves, played backwards — there is no second key for the exit, and there cannot be one: the way out is the way in reversed, always, which is what makes a card that unrolled out of the bar roll back into it rather than blinking away.
| Value | What it does |
|---|---|
none |
No animation. The card is simply there, and simply gone |
fade |
Opacity alone |
slide |
A short travel out of the bar the popup belongs to, under a fade — the default |
scale |
Grows into place from the card’s own centre, under a fade |
grow |
Unrolls out of the bar, growing from the edge the two share |
flip |
Swings open about that same edge, as though hinged there |
spin |
Turns a few degrees as it scales into place, under a fade |
slide, grow and flip take their direction from the bar the popup was opened from, so a bottom bar’s menus rise and a top bar’s drop. A menu anchored to the pointer — the desktop’s context menu, an app-directory category flyout — has no bar to travel out of and is treated as hanging below its anchor, which is where the compositor puts it.
popup_animation_duration is how long the entrance runs, in milliseconds, and it defaults to 140. The exit is four fifths of whatever you set: an entrance is paced to be followed, while a dismissal is you saying you are done with the card, and a fraction rather than a second key is what stops the two drifting into an exit longer than the entrance it reverses. It is clamped to 0–2000, and none is the off switch rather than a duration of 0 — at none nothing is wrapped and no animation controller is created at all, where 0 is the effect played instantly.
The dock ignores this key. Its hover labels and its unpin menu always open with none, whatever the theme says: the dock is a strip the pointer sweeps along, opening and abandoning a surface at every button on the way past, and a card that animated at each of them reads as the shell twitching rather than answering.
Themes written before this key existed get slide.
About the overlay animation
Section titled “About the overlay animation”The four overlay_animation* keys are popup_animation’s counterpart one layer up: how the full-screen overlays arrive. That is the settings panel, the launcher, the emoji picker, the power menu, the keybind cheat sheet, the authentication prompt and the screen-share picker — everything that dims the screen and puts a card in the middle of it. Until these keys existed they all did exactly one thing, at exactly one pace.
They are separate from the popup keys on purpose. A menu is furniture you are already reaching past, and an overlay is a surface you asked for; a theme that wants a snappy bar and a stately settings panel has to be able to say both.
overlay_animation is the shape:
| Value | What it does |
|---|---|
none |
No animation. The overlay is simply there, and simply gone |
fade |
Opacity alone — the scrim washes in and the card fades up in place |
scale |
The card grows a little into place from its own centre, under a fade — the default |
zoom |
The card settles back to size from slightly larger, as though coming towards you |
rise |
The card lifts into place from below, under a fade |
drop |
The card comes down into place from above, under a fade |
unfold |
The card unfolds vertically from its own middle, keeping its width |
flip |
The card tilts open about its horizontal middle, as though hinged there |
swing |
The card swings open about its vertical middle, like a door |
spin |
The card turns a few degrees as it scales into place, under a fade |
Every value but none fades as well as moves, and that is not decoration: an overlay is a card centred on a scrim that covers the whole screen, so an effect that only moved would slide a solid card in over a wash that was already there.
overlay_animation_curve is the pacing, and the two multiply out — a rise on an elastic and a flip on a linear are both sentences these keys can say:
| Value | What it does |
|---|---|
linear |
A constant rate, with no acceleration |
ease_in |
Starts slowly and accelerates into place |
ease_out |
Starts quickly and settles — the shell’s standard entrance, and the default |
ease_in_out |
Eased at both ends |
emphasized |
A sharp departure and a soft landing, weighted towards the end |
overshoot |
Goes a little past where it is heading, then settles back |
bounce |
Lands, bounces, and lands again |
elastic |
Springs past and oscillates before settling |
The last three deliberately travel past the resting state and come back, which is the whole point of having them; the card’s opacity is held inside its normal range while they do, so an overshoot is a movement rather than a flicker. elastic in particular wants a longer duration than the default — at 160 ms a spring reads as a stutter.
overlay_animation_duration is how long the entrance runs, in milliseconds, clamped to 0–4000. A wider ceiling than a popup’s, for the same reason these are separate keys at all. 0 is the animation played instantly; overlay_animation = "none" is the off switch, and it is a real one — no animation controller is built, nothing is layered over the card, and the window is destroyed on the frame it is dismissed.
overlay_animation_exit_ratio is how the exit relates to the entrance, and it is the second timing knob rather than a second duration. The way out is always the way in reversed — the same shape, so a card that swung open swings shut — and this says only how long it takes: 0.75 leaves in three quarters of the time it arrived in, 1.0 (the default) takes exactly as long, and the ceiling of 2.0 is there because an overlay that dissolves more slowly than it appeared is a legitimate choice. Because it is a ratio, lengthening the entrance lengthens the exit with it; the two cannot drift apart. Read backwards, an overshoot entrance becomes an anticipation dip on the way out, which is the pair those curves are drawn to make.
Two notes on what is not animated. The scrim never moves — it is the size of your display, so anything transforming or layering it would cost a full-screen composite every frame, and it arrives by its own alpha instead. And the settings panel arrives a half again more slowly than the other overlays, as it always has, because it is a workspace rather than a card you are chasing: that is a proportion of whatever you set here, not a duration of its own, so it still moves when you move overlay_animation_duration.
The notification panel keeps its own entrance, and these keys do not reach it. It is a full-height surface butted against the screen edge rather than a card in the middle of one, so it slides out of that edge and is thrown back into it; a centre-pivoted scale there opens a transparent strip along the screen edge that reads as the panel having come loose.
Themes written before these keys existed get scale at 160 ms on ease_out, which is exactly what every overlay played before there was a choice.
About font_size
Section titled “About font_size”font_size is the size of the shell’s body text — the tier most of the shell is set in — and every other size follows it. A caption stays a caption and a heading stays a heading: the whole scale is multiplied through by font_size / 13, so font_size = 16 makes everything about a quarter larger and font_size = 10 makes everything smaller, in the panels and in every popup, menu, overlay and desktop widget they open. 13.0 is the shipped value, so a theme file that does not spell the key renders exactly as it always did; midnight is the one shipped theme that moves it, at 14.0.
Two things it deliberately does not change. Panel thickness is [panels.<name>] height in config.toml, not a theme key — a bar left at its default height crops a much larger font, and the fix is to raise height alongside. Icons keep the size they are drawn at: a tray icon or a weather glyph is a picture, not type, and their sizes are [modules.*] options where they are configurable at all.
Sizes are in logical pixels and clamped to 6–32. The settings editor’s Font size field is the same key, live: the shell re-lays itself as you type, with no restart.
About blur
Section titled “About blur”blur softens what sits behind the settings and launcher panels — that is, the scrim — and nothing else. It cannot frost the desktop: a shell surface is transparent and the compositor owns everything under it, and Mir exposes no blur protocol for a client to ask for one. A theme that wants to look like glass does it with alpha, as glassy does. Set blur = 0 to skip the filter entirely.
Migrating from an inline [theme] table
Section titled “Migrating from an inline [theme] table”Older versions kept the palette in a [theme] table inside config.toml. That table is now ignored — theme is a name, not a table. To keep a palette you had customized, copy the contents of your old [theme] table into ~/.config/moonswing/themes/mine.toml (dropping the [theme] header line), delete the table from config.toml, and set theme = "mine". An un-migrated [theme] table is harmless: it costs you the theme, not the rest of your config.