Guides
Styling and themes
Backgrounds, borders, shadows and gradients, and the theme that follows the desktop.
Background, Border, Radius, Shadow and Opacity style an
element's box, and Cursor sets the pointer over it:
ui.Column(c).Padding(18).Gap(12).Radius(10).
Background(t.Background).Border(1, t.Border).
Shadow(0, 1, 3, 0, ui.RGBA(0, 0, 0, 0.06))-
Borders.
Border(1, c)draws one inside every edge;BorderWidthsets the sides apart, CSS style, withBorderColor, as a line under a header withBorderWidth(0, 0, 1, 0), andBorderStyle(ui.BorderDashed)dashes it. As with CSS'sbox-sizing: border-box, the border takes room within the element's size, and the padding and the children are inside it. -
Shadows.
Shadow(x, y, blur, spread, c)casts a box shadow, as CSS'sbox-shadowdoes: several stack, and each shows only outside the box, so a translucent background never shows its own shadow through.InsetShadow(x, y, blur, spread, c)draws an inner one, asbox-shadow: inset: inside the box's padding edge, within its border and rounded corners, above the background and below the children; the offset moves it, and a positive spread grows it inward. Inner shadows stack too, and go with outer ones, as a hairline that edges a field in the dark:field.Radius(12).Background(t.Surface). Shadow(0, 8, 24, 0, ui.RGBA(0, 0, 0, 0.3)). InsetShadow(0, 0, 1, 0, ui.RGBA(255, 255, 255, 0.2))NoShadow()takes off the shadows added so far, asbox-shadow: none, to flatten a card a helper returned with one; shadows added after it start afresh. A transition fades shadows as they change. -
Gradients and stripes.
Gradient(from, to, angle)fills the box with a linear gradient;LinearGradientalso places its colors along the line (Start,End) and mixes them in Oklab, which keeps their lightness, instead of sRGB, or takes as many colors as itsStops, each aGradientStop{Color, At}at its place from 0 to 1.ConicGradientturns its stops around a point instead, as CSS'sconic-gradient()(SwiftUI'sAngularGradient): fromAngle, in degrees clockwise from upwards, aroundCenter, the middle unless set.RadialGradientspreads them out fromCenterin circles, as CSS'sradial-gradient()(SwiftUI's and Flutter'sRadialGradient): from 0 there to 1 atRadiusDIPs away, or at the farthest corner of the box without one, as CSS'sfarthest-corner, its first color before the center and its last past the radius, for glows, spotlights and vignettes:ui.Box(c).Size(240, 140).Radius(16).RadialGradient(ui.RadialGradient{ Center: ui.Point{X: 0.3, Y: 0.2}, Oklab: true, Stops: []ui.GradientStop{{ui.Hex("#a5b4fc"), 0}, {ui.Hex("#312e81"), 1}}, })BorderLinearGradientandBorderConicGradientpaint the border with one in place of its color, as SwiftUI'sstrokeBorderwith a gradient:rainbow := []ui.GradientStop{{red, 0}, {yellow, 0.33}, {blue, 0.66}, {red, 1}} ui.Box(c).Size(120, 80).Radius(16).Border(4, ui.Color{}). BorderConicGradient(ui.ConicGradient{Stops: rainbow, Oklab: true})Stripes(c, width, gap, angle)draws stripes over the background, as on what is unavailable. A painter fills rectangles and paths with the same gradients (FillGradient,FillConicGradient,FillRadialGradient,FillPathGradient,FillPathConicGradient,StrokePathConicGradient). -
Opacity.
Opacity(o)fades the element and its children as one group, as CSS'sopacity: what they paint is put together first, then faded, so a badge over a card's edge, or two bars of a chart that overlap, show as one, not each through the other.ui.Row(c).Gap(8).Children(func() { /* … */ }).Opacity(0.5) // a toolbar, fadedChildren that overlap nothing paint faded each on their own, which looks the same at no cost; where they overlap, they render into a bitmap of the GPU first, as a blur's, its text smoothed in grayscale. Web views, and materials that show what is behind them, fade on their own, as no bitmap shows them; so does what
Disableddims. -
Blur.
Blur(sigma)blurs the element and its children, as CSS'sfilter: blur(): what they draw spreads about 3 sigma DIPs around, transparent beyond, on the GPU. They render into a bitmap first, as an effect does, every frame they paint; clicks, focus and assistive technology take the element where it is. Glows are borders blurred, a sharp one and blurred ones stacked:for _, l := range []struct{ width, blur float32 }{{6, 0}, {9, 4}, {15, 15}} { ui.Box(c).Absolute().Top(0).Left(0).Right(0).Bottom(0).PassThrough(). Radius(16).Border(l.width, ui.Color{}).BorderConicGradient(glow).Blur(l.blur) } -
Clipping to an inset.
ClipInset(top, right, bottom, left, radius)clips the element and everything inside it (background, shadows, border, children) to its box inset by those DIPs, as CSS'sclip-path: inset().ClipInsetPercenttakes percent of the box instead. A negative inset reaches beyond the box on that side, soClipInset(0, -40, 0, -40, 0)clips top and bottom while a shadow still shows left and right. The insets are drawn as a clip of the scene, not a layer, so they can change every frame. Animating one wipes the element in:hidden := float32(100) if app.open { hidden = 0 } card.ClipInsetPercent(0, card.AnimateTo("wipe", hidden, ui.Smooth), 0, 0, 12)What the inset hides takes no clicks.
-
Gradient masks.
MaskLinearGradient,MaskRadialGradientandMaskConicGradientmask the element and what is inside it by a gradient across its box, as CSS'smask-image. Each point shows as much as the gradient's alpha there; the colors don't matter. Nothing shows outside the box (or outside the inset, withClipInset). A scrolling list whose rows fade out at its edges:black := ui.RGB(0, 0, 0) fade := []ui.GradientStop{{ui.Transparent, 0}, {black, 0.08}, {black, 0.92}, {ui.Transparent, 1}} list.MaskLinearGradient(ui.LinearGradient{Angle: 180, Stops: fade})The end of clamped text fades the same way, along its width:
MaskLinearGradient(ui.LinearGradient{Angle: 90, Stops: …}). UnlikeOpacity, the mask applies to each thing drawn on its own; the element isn't drawn into a layer first. So where a row's background is faded, what is under the row shows through it too. Clicks are not masked. -
Layers of masks.
Masksmasks the element by several layers, as CSS'smask-imagewithmask-composite: each a gradient (aui.Mask'sImage, aLinearGradient,RadialGradientorConicGradient) across the border box, or across the padding box or the content box (itsBox, asmask-originandmask-clip), the first on top. Each layer goes with those under it, after it in the list, as itsCompositesays:MaskAdd, by default, shows what either shows,MaskSubtractthe layer where those under it don't show,MaskIntersectwhere both do, andMaskExcludewhere one does but not both. A gradient ring 1.5 DIPs wide, the content box cut out of the border box, as the gradient-border trick of CSS:black := ui.RGB(0, 0, 0) solid := ui.LinearGradient{From: black, To: black} ui.Box(c).Size(120, 40).Radius(20).Padding(1.5).Gradient(pink, violet, 90).Masks( ui.Mask{Image: solid, Box: ui.MaskContentBox, Composite: ui.MaskExclude}, ui.Mask{Image: solid}, )and a crescent, a disc less another beside it:
disc := func(x, y float32) ui.RadialGradient { return ui.RadialGradient{Center: ui.Point{X: x, Y: y}, Radius: 40, Stops: []ui.GradientStop{{black, 0.98}, {ui.Transparent, 1}}} } moon := ui.Box(c).Size(80, 80).Background(t.Text) moon.Masks(ui.Mask{Image: disc(0.5, 0.5), Composite: ui.MaskSubtract}, ui.Mask{Image: disc(0.7, 0.35)})Masksreplaces the gradient masks above, and they replace it. One layer across the border box masks as they do; more make a mask of their alpha, on the CPU, once for each size the element shows at, which each renderer draws as a path's clip. Like a gradient mask, it applies to each thing drawn on its own. -
Caching.
Cache()keeps what the element and its children paint in a bitmap of the GPU, as Flutter'sRepaintBoundaryand SwiftUI'sdrawingGroup()do, drawn again only when what they paint changes: other content, another size, the window's scale or theme. A still card of gradients, blurred images and large shadows around a drawing that moves then costs one image wherever a frame redraws over it, and turning or scaling the element with a transform moves the bitmap without drawing it again. The bitmap holds the box, its shadows and children beyond it that it does not clip; text over no opaque background of the subtree's own is smoothed in grayscale, as in a blur. WithBluror an effect, their bitmap is the one kept.ui.Box(c).Size(900, 212).Radius(16).Gradient(from, to, 30). Shadow(0, 10, 40, 0, shade).Cache().Children(func() { /* … */ }) -
Materials.
Material(m)fills the box with a material a package paints, in place of a background, as the glass plugin's Liquid Glass, which what is under it shows through, frosted and bent along its edges, its scroll edges and its backdrop blur, which may fade along a gradient. -
Pointer.
Cursortakes the shapes of the platforms:CursorPointer,CursorText,CursorMove,CursorGrabandCursorGrabbing, the resize cursors (both ways, toward one side asCursorResizeE, and of columns and rows),CursorCopyandCursorAliasfor drops,CursorContextMenu,CursorVerticalText,CursorNotAllowed,CursorCrosshair,CursorNone, which hides it, CSS'sprogressandwait(CursorProgressover a control that is busy but still takes input, as one witharia-busy, andCursorWaitwhile nothing can be done),CursorHelp,CursorCell,CursorZoomInandCursorZoomOut,CursorAllScrollandCursorNoDrop. Windows has no cursors for zooming and cells, which MyGo draws, as browsers bring their own; macOS hasCursorWait,CursorHelp, the zoom cursors andCursorAllScrollamong AppKit's private cursors, as WebKit uses them, and shows the nearest public one where a version lacks them (the crosshair forCursorCell). An element's cursor shows over its children too, except over those that take clicks (a button, or anything withClickedorOnClick): those show their own, the default arrow unless they set one, so the buttons of a field withCursorTextshow no I-beam.
ui.Oklch(l, c, h) is CSS's oklch(); .Alpha(a) is its / a. It
returns a ui.Color like any other, so fills, borders, gradients,
stripes, shadows, dividers, text, decorations, text backgrounds, the
glass's tint and the theme (t.Accent = ui.Oklch(0.6, 0.2, 240)) all take
it. It can name colors outside sRGB, more vivid than most screens show.
Such a color keeps both: its real value, drawn when the window is on a
screen that shows a wider range (Display P3 on recent Macs), and the
nearest sRGB color, chosen as CSS does (less saturated, same lightness and
hue). That fallback is its R, G and B, and is what sRGB screens,
Linux, Windows and SVG pictures draw; SRGB() returns it without the real
value. Mix and transitions mix the real values: a mix back inside sRGB
is an ordinary color. Don't assign R, G or B of such a color, as the
real value would no longer match: make a new one.
Colors come from ui.RGB, ui.RGBA and ui.Hex("#2563eb"); Mix blends
two, MixOklab blends them in Oklab, as a gradient's Oklab does (yellow
and blue meet in a blue rather than in sRGB's gray), and Alpha makes one
translucent.
State, not selectors
There are no style sheets and no state selectors: the view is code, so an
element's look follows the state in the view itself, as
if row.Hovered() { row.Background(t.SurfaceHover) }, and one element's
state can style another, as a group's hover does in CSS.
A widget returns its element, so a call after it styles it differently
from the rest: ui.Button(c, "Save").Padding(10, 20).Radius(999). For a
look of your own, build on the widgets' bases, which have none: see
custom widgets.
Themes
Widgets take their colors and metrics from the theme, c.Theme(): the
light or the dark theme, following the system's appearance as it changes,
and the settings of the desktop that the system's own controls follow. Use
its colors in your own elements so they follow too. To change it, set a
copy:
// The default, which follows the system, in an accent of the app's,
// with its hover, pressed, selection and focus shades.
t := *c.Theme().WithAccent(ui.Hex("#7c3aed"))
t.Radius = 8
t.Spacing = 3 // compact
// A square bar, as VS Code's editor.
t.ScrollbarStyle = ui.ScrollbarStyle{
TrackWidth: 14, ThumbWidth: 14, Square: true, Flush: true, MinLength: 20,
}
c.SetTheme(&t)Spacing is the unit of the room widgets leave: their paddings and gaps,
and the sizes of check boxes, switches, sliders and the rows of tables and
trees, are multiples of it. It is 4 by default; 3 makes every widget
compact, 5 roomy. FontSize sizes their text, Radius rounds their
corners, and Scrollbar and ScrollbarWidth color and size scroll bar
thumbs, which ScrollbarStyle styles further: tracks, hover and drag
colors, widths, corners and the shortest thumb. On macOS,
rounded corners, the widgets' and your elements', curve continuously as
AppKit's and SwiftUI's do, and elsewhere as quarter circles, as Windows
and GTK draw them. Size your own
elements with the theme too, and they follow it: t.Space(3) is three
units of its spacing, and t.Rem(2) twice its font size, as CSS's rem.
Tooltips and toasts are the theme turned over, so that they stand out from
what they are over: filled with Inverse, with InverseText on it, which
left zero are the theme's Text and Background. A dark app that would
rather keep them dark sets both:
t.Inverse, t.InverseText = ui.Hex("#3f3f46"), ui.Hex("#fafafa")TooltipDelay is how long the pointer rests on an element before its
tooltip shows, 0.6 seconds when left zero (see
Tooltip).
In a window that is not the one the user's input goes to, the widgets
fade their accent to gray, as AppKit fades an inactive window's (see
Active and inactive windows):
selected rows, checked controls, sliders and progress bars are filled
with InactiveAccent, which left zero is a gray AccentText stands out
on, and primary buttons look as the others. A theme that keeps its accent
in every window sets it to the accent:
t.InactiveAccent = t.AccentThe focus ring
The element with the keyboard focus from the keyboard shows a ring, which
the theme draws: FocusRingWidth DIPs wide (2 by default), its inner edge
FocusRingOffset DIPs outside the element's edge (1 in the light and dark
themes; 0 puts it on the edge, and a negative offset inside it), in
Focus. Its corners follow the element's, as CSS's outline does. A theme
of a thicker ring, further out:
t.FocusRingWidth, t.FocusRingOffset = 3, 2An element sets its own offset with FocusRingOffset, and its color with
FocusRingColor, as a control whose ring must sit inside it because an
element around it clips what goes past its edges, as a header in an
accordion's box does:
header.FocusRingOffset(-4) // inside, its outer edge 2 DIPs inWidgets that ring a part of themselves, as a check box its box, take the
same settings. FocusRing(false) turns the ring off for a widget that
draws its own with Painter.FocusRing, the theme's ring around any
rectangle, while FocusVisible.
A theme for part of the window
SetTheme themes the whole window: the root, its background and its text,
and the widgets built after the call. ui.Themed(c, t) themes a part of
it instead: a column whose children build with t, as a theme picker
previewing light beside dark, or a light preview pane inside dark chrome:
ui.Row(c).Fill().AlignItems(ui.Stretch).Children(func() {
for _, t := range []*ui.Theme{ui.LightTheme(), ui.DarkTheme()} {
ui.Themed(c, t).Grow(1).Padding(16).Gap(8).Children(func() {
ui.Text(c, "Preview")
ui.Button(c, "OK")
})
}
})Inside, everything is as in a window of t: c.Theme() returns it while
the children build, and Painter.Theme() while they paint, so widgets of
your own follow too; the widgets take its colors and its metrics
(Spacing, Radius, FontSize, ScrollbarWidth, ScrollbarStyle, so
t.Space and t.Rem); and the popovers, menus, tooltips and dialogs
opened from inside take it, though they show above the window. The column
paints t's
Background, and its text takes t's Text color, FontSize and
Font, as the root does the window's theme's; .Background(ui.Transparent)
leaves what is behind it showing instead, as for the root. The root, and
what is built after the column, keep the window's theme; toasts are the
window's, and keep it too. Themes nest, and a nil theme makes it a plain
column. SetTheme called inside still themes the window, from the root to
what follows the column, while the column's children keep t.
The desktop's preferences
c.Preferences() returns the settings of the desktop, which the default
theme follows and a frame follows the changes of:
-
Accent. The accent color the user chose (macOS, Windows, and desktops whose portal gives one, as GNOME 47 and KDE do), which colors primary buttons, the choice and the focus ring, with text on it that stands out. -
HighContrast. macOS's Increase Contrast, Windows's contrast themes, the portal's higher contrast: borders and secondary text are darker (lighter in the dark), and the focus ring opaque. -
TextScale. Windows's and GNOME's text size:FontSizeis that many times larger. -
ReduceMotion. macOS's Reduce Motion, Windows's animation effects and GNOME's animations turned off:Animategoes to its target at once. -
Pointer,AnyPointer,Hover()andAnyHover(). What the pointing devices can do, as Media Queries 4'spointer,any-pointer,hoverandany-hover:Pointeris how accurately the primary one points,ui.PointerFinefor a mouse, a touchpad or a pen,ui.PointerCoarsefor a touch screen,ui.PointerNonefor none, andAnyPointerthe most accurate of all;NoHoverandNoAnyHover(Hover()andAnyHover()the other way round) tell that the primary one, or every one, cannot hover. A touch-first layout gives larger targets to a coarse pointer, and shows on hover alone only what a pointer that hovers can reach:p := c.Preferences() size := float32(28) if p.Pointer == ui.PointerCoarse { size = 44 } del := ui.Button(c, "×").Size(size, size).Label("Delete") if p.Hover() { del.Tooltip("Delete") }Linux reads the seat's devices (GDK's), and follows them as they come and go; Windows reads whether a mouse, a pen or a touch screen is there, the touch screen being the primary one in tablet mode; browsers answer the media queries. macOS's, and those not known, are a mouse's: fine, and hovering.
A theme of your own (SetTheme) does not follow them: it is yours. To
have it follow them as the default theme does, as an app that lets the
user pick light or dark over the system's appearance would, take them
with WithPreferences, which returns a copy of the theme in the desktop's
accent, with its contrast and text size:
t := ui.DarkTheme()
t.Background = ui.Hex("#18181b")
c.SetTheme(t.WithPreferences(c.Preferences()))A theme that already follows them, as c.Theme() and what
WithPreferences returns do, follows the settings given in their place:
what following changed goes back first, so text is not made larger twice
and a setting the user turned off goes, while colors you set since stay.
WithAccent takes an accent alone, the desktop's or one of
your own, with the shades the default theme gives it: AccentHover,
AccentPressed, Selection and Focus become shades of the accent,
lighter in the dark, and AccentText white, or near black on an accent as
light as yellow. An accent with an A of 0, as where the desktop has
none, keeps the theme's own:
t := ui.LightTheme().WithAccent(ui.Hex("#7c3aed")) // a brand color