Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Paint

How a node looks: fills, gradients, corners, borders, clipping, masks, shadows and the four blurs. Layout and per-kind properties are on Nodes; easing any of these values is on Animation.

column {
    padding = 16,
    spacing = 8,
    background = "#1E1E2EF2",
    radius = 12,
    border_width = 1,
    border_color = "#FFFFFF1A",
    shadow_color = "#00000099",
    shadow_blur = 18,
    shadow_offset = { x = 0, y = 8 },
    children = {
        text { content = "Battery", font_size = 14, foreground = "#CDD6F4" },
        text { content = "82% · 3 h 10 min left", foreground = "#A6ADC8" },
    },
}

A card: a translucent rounded fill, a hairline border and a soft shadow below it.

Terms

TermMeaning
Box kindA node that paints a box: rect, row, column, button and the four surface roles (panel, window, popup, lock)
RepaintMantle redraws the changed part of a surface’s buffer; an unchanged surface is not redrawn
Offscreen passThe subtree is drawn into a temporary texture, filtered or masked, then composited back. Costs a texture and an extra draw
LayerThe offscreen pass that content_blur and some shadows use. Unlike other offscreen passes, Mantle keeps it and reuses it while the subtree does not change
GlassA box with backdrop_blur
SigmaA Gaussian blur’s standard deviation in logical px. The blur reaches about 3 sigma

Who takes what

A property on a kind that does not take it is refused, naming the closest property the kind takes or, with none close, listing them all.

PropertiesTaken by
shadow_color, shadow_blur, shadow_offset, shadow_spread, content_blur, opacityEvery node, including text, icon, image, list, textfield
background, radius, corner_shape, border_color, border_width, clip, mask, shadow_mode, backdrop_blur, blurBox kinds only
source_blurimage only
foreground (text, icon, textfield), z, scale, rotate, translate, origin, visibleAlso affect paint; documented on Nodes

Every property can be a signal. A signal nested inside a table (a gradient stop, one border edge) is refused, so derive the whole table with :map. A malformed value fails the pass instead of drawing a default: the previous scene stays and the error goes to mantle log (runtime).

Colours

Colours are strings "#RRGGBB" or "#RRGGBBAA", hex digits in either case. There are no named colours and no short #RGB form.

Box properties

PropertyTypeDefaultBehaviour
backgroundColor|Gradient|BoundNoneA colour or gradient. Absent draws nothing; "#00000000" is an explicit transparent fill. A gradient snaps under animate
maskMask|BoundNoneMultiplies the alpha of this node and its subtree; see Mask
radiusnumber|Bound, [0, 8192]0Corner radius px. Above half the shorter side it clamps, so radius = 999 makes a pill or circle
corner_shape"Round"|"Scoop"|Bound"Round""Scoop" cuts each corner inward as a quarter circle centred on the corner point; fill, clip, glass, shadow and the blur region follow
border_colorColor|BorderColors|BoundNoneA string sets all four edges; a missing edge has none. An edge draws only with both a colour and a width
border_widthnumber|Edges|Bound, [0, 8192]0Px per edge; a number sets all four, a missing edge is 0. Borders draw inside the box and take no layout space
blurboolean|BoundfalseAsk the compositor to blur the desktop behind this box; see Blurs. Never inferred from a translucent background
backdrop_blurnumber|Bound, [0, 8192]0Gaussian sigma in px over what this surface already painted under the box, CSS backdrop-filter; see Blurs
shadow_mode"Box"|"Content"|Bound"Box""Box": CSS box-shadow of the box shape. "Content": CSS drop-shadow of everything painted. See Shadows
clip"Box"|"Rounded"|"None"|Bound"Box""Box" cuts children to the rectangle, "Rounded" also to radius, "None" leaves them on the parent’s clip. See Clip

A uniform border (same width and colour on all four edges) on a round corner follows radius. A per-edge border, or any border on a scoop, draws as four straight rectangles with square corners:

local function tile(label, props)
    props.width, props.height, props.radius = 88, 56, 14
    props.background = "#313244"
    props.children = { text { content = label, foreground = "#CDD6F4", align_h = "Center", align_v = "Center" } }
    return rect(props)
end

return row {
    spacing = 12,
    children = {
        tile("Round", { border_width = 2, border_color = "#89B4FA" }),
        tile("Scoop", { corner_shape = "Scoop", border_width = 2, border_color = "#89B4FA" }),
        tile("Per-edge", { border_width = { bottom = 3 }, border_color = "#89B4FA" }),
    },
}

Gradients

background and mask take a gradient table.

background = {
    gradient = "Linear",
    angle = 90,
    stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } },
}
KeyRule
gradient"Linear", "Radial" or "Conic"
angleDegrees clockwise from the top, as in CSS. Linear default 180 (top to bottom), Conic default 0 (starts at twelve o’clock). Radial refuses it
stopsAt least 2 { position, colour } pairs. Positions in [0, 1], never descending; two equal positions make a hard edge
ShapeGeometry
LinearAlong angle through the centre, long enough that the corners take the end stops (CSS)
RadialAn ellipse from the centre out to the box’s edges, not its corners
ConicA turn around the centre, starting at angle
local stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } }

local function swatch(label, fill)
    return column {
        spacing = 6,
        children = {
            rect { width = 96, height = 64, radius = 8, background = fill },
            text { content = label, foreground = "#A6ADC8" },
        },
    }
end

return row {
    spacing = 12,
    children = {
        swatch("Linear, 90", { gradient = "Linear", angle = 90, stops = stops }),
        swatch("Radial", { gradient = "Radial", stops = stops }),
        swatch("Conic", { gradient = "Conic", stops = stops }),
    },
}

Clip

clip decides what a box cuts its children to.

ValueChildren are cut toCost
"Box"The box’s rectangleFree (a scissor)
"Rounded"The box’s radius and corner_shape. With radius = 0 it is "Box"An offscreen pass every repaint of the box
"None"Whatever the parent cuts to, so children and their shadows can overflow this boxFree

A rounded clip draws in the order fill, children, border, so the border stays on top of children that reach the arc.

Mask

mask multiplies the alpha of the node’s own fill and border and of its whole subtree.

FormAlpha taken from
A gradient tableThe gradient’s colours’ alpha, laid over the box. RGB is ignored
{ source = "/path.png" }The image’s alpha, stretched over the box. A file that fails to load leaves the node unmasked
Either, plus invert = trueThe complement: kept and cut swap

Name exactly one of source or a gradient. A masked box draws its subtree offscreen every repaint and always cuts children to its box (to radius too under clip = "Rounded"), even with clip = "None".

local items = {}
for i = 1, 12 do
    items[i] = rect { width = "Fill", padding = 10, radius = 8, background = "#313244",
        children = { text { content = "Row " .. i, foreground = "#CDD6F4" } } }
end

return column {
    width = 200,
    height = 240,
    spacing = 6,
    scroll = scroll("feed"),
    mask = {
        gradient = "Linear",
        stops = { { 0, "#00000000" }, { 0.08, "#000000" }, { 0.92, "#000000" }, { 1, "#00000000" } },
    },
    children = items,
}

A scrolling list whose rows fade out at the top and bottom edges.

Shadows

A shadow draws when shadow_color has alpha above 0 and at least one of shadow_blur, shadow_offset or shadow_spread is set. The terms are CSS’s box-shadow.

PropertyValuesDefault
shadow_colorColour"#000000"
shadow_blurCSS blur radius in px [0, 8192]; the Gaussian’s sigma is half of it0
shadow_offset{ x, y } px, each [-8192, 8192], missing axis 0{ x = 0, y = 0 }
shadow_spreadpx [-8192, 8192] the shape grows (negative shrinks) per side. On a non-box shadow it scales the shadow about the box centre instead0
shadow_modeBox kinds only. "Box": CSS box-shadow, cast by the box’s shape and cut out under the box. "Content": CSS drop-shadow, cast by everything the node and its subtree paint"Box"

Non-box nodes (text, icon, image, …) have no box to cast, so their shadow is always the content’s: text gets a glyph-shaped shadow. The same unfilled, bordered box in each mode:

local function card(mode)
    return column {
        padding = 14,
        radius = 12,
        border_width = 1,
        border_color = "#89B4FA",
        shadow_mode = mode,
        shadow_color = "#000000",
        shadow_blur = 4,
        shadow_offset = { x = 5, y = 6 },
        children = { text { content = mode, font_size = 20, foreground = "#CDD6F4" } },
    }
end

return row {
    padding = 24,
    spacing = 24,
    background = "#585B70",
    children = { card("Box"), card("Content") },
}

"Box" casts the rounded box and cuts the shadow out under it; "Content" casts the border ring and the glyphs.

CaseHow it draws
Box mode on a round box, any fillOne gradient quad around the box. On a translucent box it is cut out under the box, so it never shows through the fill
An opaque box (solid colour fill with alpha 1, no mask, no content_blur, opacity 1), either modeThe same gradient quad; the box covers what is under it
Content mode on anything else, any non-box node, an opaque scoopAn offscreen layer: the subtree is drawn, blurred and tinted shadow_color
Box mode on a translucent scoopA layer of the scoop’s silhouette, cut out under the box

Blurs

Four properties blur four different things. Sigmas are in logical px, [0, 8192], 0 is off. source_blur is a fast box approximation; the others are Gaussian.

PropertyReadsWhen it runsCostPick it for
blur = true (box kinds)The desktop behind the surface: other windows and the wallpaper, not this surface’s own pixelsContinuously, in the compositorThe compositor’sA translucent bar or panel over windows
backdrop_blur = sigma (box kinds)What this surface has already painted under the box: ancestors, earlier siblings, lower z. Never the desktopEvery repaint that touches the box or what it reads, on the GPUA copy and a blur per repaint; not cachedGlass over the surface’s own wallpaper, image or animated content
content_blur = sigma (every node)The node’s own subtreeOn repaint, on the GPU, into an offscreen layerA blur when the subtree changes; an unchanged layer is reused. Large sigmas downsample firstA blurred or blur-in element, tweened with animate
source_blur = sigma (image)The image file’s pixelsOnce, on the CPU, when the source decodesNothing per frameA static blurred picture on a surface that repaints often

blur = true. Mantle sends the compositor a region, through ext-background-effect-v1, made of every blur = true box on the surface: rounded to radius (or scooped), cut by ancestor clips, moved by transforms, and dropped while the node is invisible or at opacity 0. It ignores mask. The compositor decides strength, noise, xray and whether to blur at all; a compositor without the protocol or its blur capability gives nothing, and no error. It is never inferred from a translucent background; the background alpha only decides how much of the blurred desktop shows through.

source_blur. The blur is baked into the decoded pixels, which are stored cropped to the box under fit = "cover", so it is exact there. Under "contain" or "stretch" the stored pixels are rescaled and the blur with them. Animated GIFs ignore it. Changing it re-decodes; under async = true the image draws nothing until that lands, and retain does not cover it (the source did not change). See image.

panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 36,
    exclusive = true,
    background = "#1E1E2E99",
    blur = true,
    child = row { width = "Fill", padding = { left = 12, right = 12 }, children = { clock } },
}

A bar whose 60% fill tints the compositor-blurred desktop behind it.

rect {
    width = 320,
    height = 180,
    children = {
        image { source = "/usr/share/backgrounds/default.png", width = "Fill", height = "Fill", async = true },
        row {
            align_h = "Center",
            align_v = "Center",
            padding = { left = 14, right = 14, top = 6, bottom = 6 },
            radius = 999,
            background = "#FFFFFF1F",
            border_width = 1,
            border_color = "#FFFFFF33",
            backdrop_blur = 12,
            children = { text { content = "12:45", font_size = 18, foreground = "#FFFFFF" } },
        },
    },
}

A frosted pill: the image is painted first, so the pill’s backdrop_blur blurs the image under its rounded shape, and the fill tints it. The same pattern over a full-screen image frosts a lock screen’s wallpaper.

Combining effects

One node paints in this order, each step over the last:

  1. Backdrop (backdrop_blur): replaces the pixels under the box with their blur.
  2. Shadow, when it is a gradient quad or a silhouette.
  3. Body: fill, children in z order, border. With a mask or a clip = "Rounded" the body goes through an offscreen pass.
  4. Layer: for content_blur or a layered shadow, the body is drawn offscreen, its shadow cast from it, then the body blurred.
  5. Transform (scale, rotate, translate) wraps all of the above.
CombinationWhat happensDo this
mask and backdrop_blur on one nodeThe mask fades the fill, border and subtree, not the node’s own glass or box shadowPut the glass on a child of the masked node
content_blur and backdrop_blur on one nodeThe glass stays sharp; only the fill, border and subtree blurExpected
backdrop_blur inside a parent with mask, content_blur or a Content-mode shadowThe glass sees only what that parent has drawn so far, not what is under the parentMove the glass out of the effect parent, or accept it
backdrop_blur inside clip = "Rounded" without a maskThe glass sees what is under the parent, as without the clipNothing to do
backdrop_blur on a surface rootNothing is under it on the surface, so it blurs transparencyUse blur = true for the desktop
blur = true and backdrop_blur on one boxThe compositor blurs the desktop; the backdrop blurs this surface’s pixels. Neither sees the otherPick by what is underneath: desktop or own content
Shadow and content_blur on one nodeThe shadow is cast from the sharp content, then the content is blurredExpected
Box-mode shadow on a translucent boxOne gradient quad, cut out under the box; children do not castshadow_mode = "Content" to cast from what is painted
Content-mode shadow on a masked nodeCast from the masked resultExpected
Content-mode shadow or content_blur over an image, icon, capture, image mask or glassThe layer is redrawn every repaint instead of reusedKeep those out of animated layers, or accept the cost
Anything under a glass changesThe glass repaints, and so does everything in the area it reads (3 sigma past its box)Keep glass away from constantly animating content, or keep sigma small
Shadow or content_blur near the parent’s edgeCut at the parent’s clip, like any child paintGive the parent padding, or clip = "None" on it
opacity on a node with effectsMultiplied into every draw once; layers and clips composite at full alpha, so nothing fades twiceExpected
opacity < 1 on a group whose children overlapEach child fades on its own, so overlaps show through each other (not CSS group opacity)For a group fade, give the parent a uniform mask (e.g. both stops "#00000080"); it costs an offscreen pass
A transform on a node with a glass or shadowThe backdrop, shadow and body move together; the glass reads under its transformed positionExpected

How do I…

TaskAnswer
Frosted glass panel over windowsGlass sheet below, or the blur bar
Frost a picture inside my own surfaceThe frosted pill: an image, then a sibling with backdrop_blur
Card with a shadowThe card at the top; lift on hover below
Pill buttonPill button
Gradient borderGradient ring
Fade a list’s edgesThe edge-fade mask
Circular avatarAvatar
Dim the background behind a modalScrim
Tint a gradient from a signalMap the whole table; see Gotchas

Frosted glass panel

panel {
    id = "sheet",
    layer = "Top",
    anchor = { top = true, right = true },
    margin = 8,
    child = column {
        width = 280,
        padding = 16,
        spacing = 8,
        radius = 16,
        background = "#1E1E2EB3",
        border_width = 1,
        border_color = "#FFFFFF2E",
        blur = true,
        children = { text { content = "Wi-Fi", font_size = 14, foreground = "#CDD6F4" } },
    },
}

The compositor blurs the desktop under the rounded sheet only; the rest of the surface stays clear. A faint light border separates glass from glass.

Card that lifts on hover

local lifted = hover("card_hover")
column {
    hover = lifted,
    padding = 16,
    radius = 12,
    background = "#313244",
    shadow_color = "#00000099",
    shadow_blur = lifted:map(function(on) return on and 36 or 12 end),
    shadow_offset = lifted:map(function(on) return { x = 0, y = on and 20 or 6 } end),
    animate = { shadow_blur = 200, shadow_offset = 200 },
    children = { text { content = "Hover me" } },
}

hover drives the shadow and animate eases it. Leave room around the card: the parent clips the shadow.

Pill button

local hovered = hover("save_hover")
button {
    hover = hovered,
    padding = { left = 16, right = 16, top = 6, bottom = 6 },
    radius = 999,
    background = hovered:map(function(on) return on and "#89B4FA59" or "#89B4FA33" end),
    border_width = 1,
    border_color = "#89B4FA66",
    animate = { background = 150 },
    on_click = function() print("saved") end,
    children = { text { content = "Save", foreground = "#CDD6F4" } },
}

A radius past half the height makes the ends round whatever the label’s width.

Gradient border

rect {
    padding = 2,
    radius = 14,
    background = { gradient = "Linear", angle = 135, stops = { { 0, "#CBA6F7" }, { 1, "#89B4FA" } } },
    children = {
        column {
            padding = 14,
            radius = 12,
            background = "#1E1E2E",
            children = { text { content = "Pro", foreground = "#CDD6F4" } },
        },
    },
}

border_color takes only flat colours, so paint the gradient as an outer fill and cover all but a 2px ring with an opaque inner box. Keep the inner radius the outer radius minus the ring width.

Circular avatar

rect {
    width = 64,
    height = 64,
    radius = 32,
    clip = "Rounded",
    border_width = 2,
    border_color = "#89B4FA",
    children = { image { source = "/var/lib/AccountsService/icons/user", width = "Fill", height = "Fill" } },
}

clip = "Rounded" cuts the image to the circle, and the border paints over the image’s edge.

Dim the background behind a modal

panel {
    id = "modal",
    layer = "Overlay",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "Fill",
    height = "Fill",
    exclusive = "Ignore",
    keyboard_interactivity = "OnDemand",
    child = rect {
        width = "Fill",
        height = "Fill",
        children = {
            rect { width = "Fill", height = "Fill", background = "#11111B99" },
            column {
                align_h = "Center",
                align_v = "Center",
                width = 360,
                padding = 24,
                spacing = 8,
                radius = 16,
                background = "#1E1E2EE0",
                blur = true,
                shadow_color = "#00000080",
                shadow_blur = 32,
                children = {
                    text { content = "Log out?", font_size = 18, foreground = "#CDD6F4" },
                    text { content = "Unsaved work in open apps will be lost.", foreground = "#A6ADC8" },
                },
            },
        },
    },
}

A full-screen panel whose first child is a translucent scrim and whose second is the dialog. Dim with a colour rather than blur = true on the scrim: the compositor’s blur does not fade with opacity, so a fading scrim would blur at full strength until it hits 0.

Gotchas

TrapFix
A shadow is cut off at one edgeThe parent clips it. Pad the parent, or set clip = "None" on it
backdrop_blur shows no desktop behind a translucent panelIt only reads this surface’s pixels. Use blur = true
blur = true does nothingThe compositor lacks ext-background-effect-v1 or its blur capability. No error is raised
A blur = true box fades out but its blur stays at full strengthThe blur region ignores opacity until it reaches 0. Dim with a translucent colour, or let the blurred box pop
A per-edge border or a border on a scoop has square cornersOnly a uniform border follows radius, and a scoop’s border is always square
A border covers contentBorders take no layout space. Add padding at least the border’s width
clip = "Rounded" changes nothingIt needs a non-zero radius, and only clips children
Children still clipped with clip = "None" and a maskA mask always cuts to its box
A gradient or a per-edge border_color jumps instead of easing under animateOnly single colours ease; see Animation
Rounded corners, scoops and masks still take clicks in the cut-away areaHit-testing uses the rectangle. Shrink the button or accept it
A signal inside a gradient stop or border edge is refusedMap the whole table: background = accent:map(function(c) return { gradient = "Linear", stops = { { 0, c }, { 1, "#00000000" } } } end)

See also: nodes, surfaces, animation, input, glossary.

Source: allowlist, parsers, paint style, paint order, canvas, effects, shapes, blur region, compositor push.