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

Styling

A small CSS: four fixed layers, a closed set of selectors and properties, custom properties for everything a theme decides, and transitions that cannot run layout.

By the end of this chapter you can load an application stylesheet, select a widget and its parts, know which properties resolve and which are refused, swap a theme and a density at run time, and animate the properties the toolkit lets you animate.

#root   { flex-direction: column; background: var(--gb-bg); color: var(--gb-text) }
#bar    { height: 44px; padding: 0 16px; gap: 12px; align-items: center }
.screen { flex-direction: column; gap: 16px; padding-top: 16px }

button.danger:hover { background: var(--gb-button-danger-bg-hover) }
private final Stylesheet styles =
        Stylesheet.resource(CascadeLayer.APPLICATION, Hello.class, "app.css");

@Override public List<Stylesheet> stylesheets() {
    var sheets = new ArrayList<>(Controls.stylesheets(Theme.NORD_DARK));
    sheets.add(styles);
    return sheets;
}

Stylesheet.resource reads a file beside the class and parses it at start-up. A broken stylesheet is a CssSyntaxException with a position, because a window that opens unthemed and says nothing is worse than one that refuses to open. Stylesheet.parse(layer, text) does the same for a string.

The cascade: four layers

public enum CascadeLayer { TOOLKIT_BASE, THEME, APPLICATION, INLINE }
LayerHoldsWho writes it
TOOLKIT_BASEcontrols.css: every control’s metrics and structure, reading colours through var(--gb-*)the toolkit, Controls.baseStylesheet()
THEMEnord-light.css or nord-dark.css: the custom properties, plus the density and scrollbar sheetsthe toolkit, Theme.NORD_DARK.load()
APPLICATIONyour stylesheetsyou
INLINEwhat only a widget can compute, through Styled.restylea widget author

At equal specificity a later layer wins, so an application rule beats a theme rule beats a base rule. Specificity is CSS’s: an id beats a class beats a type. There is no @layer, and the order cannot be argued with by a stylesheet that loads late.

INLINE has no style= attribute behind it. It is the layer a widget writes into for a value no selector can express, such as the position of the third of five segments (ADR-0099).

Selectors

button                    { }       /* type */
.primary                  { }       /* class */
#save                     { }       /* id */
panel text                { }       /* descendant */
row > button              { }       /* child */
checkbox:hover check-indicator { }  /* a part, under a state */
:root                     { --gb-accent: #b48ead }

The pseudo-classes are a closed set. A typo such as :hovered is a stylesheet error rather than a rule that never matches.

Pseudo-classTrue whenSet by
:hoverthe pointer is over the node or a descendantthe router
:activea press is held on the node or a descendantthe router
:focusthe node has keyboard focus, however it got itthe router
:focus-visiblethe node has focus from the keyboardthe router
:disabledthe widget says it is disabledthe widget
:checkedthe widget says it is onthe widget
:indeterminatea tri-state control is mixedthe widget
:invalida field or its control failed validationthe widget
:affixedan affix has pinned itselfthe widget
:rootthe root element, where a theme hangs its propertiesstructure

:hover and :active reach the whole ancestor chain, which is what lets checkbox:active check-indicator light the glyph when the label is pressed. The router refuses to set :hover or :active on a disabled widget, which is how a disabled control stays dull without :not(:disabled):hover.

Not in the subset: :not(), :focus-within, attribute selectors, sibling combinators, and :: pseudo-elements. A part is selected by its type name instead. A widget that wants to react to focus inside its subtree implements Handles.onFocusWithin rather than matching a selector.

Parts

A control with two surfaces a theme must style differently is two cascade nodes. The inner one is a part: it has a type name, matches pseudo-classes and takes properties, and it cannot be written in a document (ADR-0065).

checkbox                         { gap: 8px }
checkbox check-indicator         { border-radius: 4px }
checkbox:checked check-indicator { background: var(--gb-checkbox-bg-checked) }
toggle-track:checked toggle-thumb { transform: translate(16px) }
scroll:hover scroll-thumb.vertical { width: 10px }

check-indicator, toggle-track, toggle-thumb, slider-value, scroll-thumb, field-message and tab-panel are parts. Each widget’s chapter lists its own under Styling.

Properties

The subset is closed. A property the engine does not know is logged at debug and ignored. A known property with a value it cannot read is dropped with a warning that quotes the text.

Box and layout

.toolbar {
  flex-direction: row;
  flex-wrap: wrap;
  justify-content: flex-start;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  margin: 0 auto;
  width: 100%;
  min-height: 32px;
  overflow: hidden;
}
.toolbar > button { flex-grow: 1; flex-shrink: 0; flex-basis: 120px; align-self: flex-end }
.badge { position: absolute; top: 4px; left: 4px }

flex-direction, flex-wrap, flex-grow, flex-shrink, flex-basis, justify-content, align-items, align-self, align-content, gap, padding and its four sides, margin and its four sides, width, height, min-width, max-width, min-height, max-height, position, inset, top, right, bottom, left, overflow. These compile to Yoga. margin: auto centres on the main axis (ADR-0311). Lengths are px, %, em and rem. There is no calc() and no display: none.

Text flow

.cell    { white-space: nowrap; text-overflow: ellipsis; overflow: hidden }
.readout { text-align: end }
link     { text-decoration: underline }

white-space: normal | nowrap, text-overflow: clip | ellipsis, text-align: start | center | end, and text-decoration or text-decoration-line: none | underline | line-through. left and right are refused for text-align, and so is justify (ADR-0256).

Typography

.heading { font-family: Inter; font-size: 15px; font-weight: 600; line-height: 20px }
.mono    { font-family: "JetBrains Mono" }
.note    { font-style: italic }

font-family, font-size, font-weight, font-style and line-height. A weight is a face, so font-weight: bold resolves to the nearer face that exists, which is 600 (ADR-0066). font-style: oblique is refused because nothing shears a glyph (ADR-0323). font-family: Inter, sans-serif takes the first name and discards the rest. There is no letter-spacing.

Colour

panel  { background: var(--gb-surface); color: var(--gb-text) }
.faded { opacity: 0.5 }
.wash  { background-color: rgba(136, 192, 208, 0.2) }

background, background-color, color and opacity. A colour is a hex value, rgb() or rgba(), transparent, or a var(). opacity on a node with children renders the subtree into a layer and composites it once, so two overlapping children do not show through each other (ADR-0071). A translucent leaf keeps the cheap path.

Border, outline and shadow

card   { border: 1px solid var(--gb-border); border-radius: 8px; box-shadow: var(--gb-elevation-1) }
.tab   { border: 1px solid var(--gb-border); border-bottom: none }
button:focus-visible { outline: 2px solid var(--gb-focus); outline-offset: 2px }

border and border-top, -right, -bottom, -left, each <width> || <style> || <color> in any order. border-width and border-color take one to four values. border-{side}-width and border-{side}-color. border-radius takes one to four corners. outline, outline-width, outline-color and outline-offset. box-shadow is one shadow, not a list.

A border takes no layout room: it is drawn inside the box’s edge, over the padding (ADR-0505). Every style keyword is drawn solid, and there are no -style longhands. An outline is drawn outside the box and takes no room either, so a focus ring cannot move a control by appearing.

Transform

panel.turned   { transform: rotate(20deg) }
panel.grown    { transform: scale(1.4); transform-origin: left top }
toast.entering { transform: translate(0, 16px) }

transform with translate, scale and rotate, and transform-origin. Layout runs first and the matrix moves the result, so a transform costs no layout pass. Hit testing maps the pointer back through the same matrix, so a scaled button responds where it is drawn (ADR-0068).

Transition and animation

button        { transition: background-color var(--gb-motion-fast) ease-enter }
button:active { transition: background-color 0ms }

@starting-style { toast { opacity: 0; transform: translate(0, 16px) } }

@keyframes pulse { from { opacity: 0.4 } to { opacity: 1 } }
skeleton { animation: pulse 1.2s linear infinite }

transition, animation and its seven longhands: animation-name, -duration, -timing-function, -delay, -iteration-count, -direction and -fill-mode.

The animatable properties are a closed whitelist: opacity, background-color, border-color, color and transform. transition: width 200ms is a dropped declaration with a warning naming it, because animating a width would run layout on every frame (ADR-0067).

The timing that applies is the one on the style being moved to, so the two button rules above make a press snap and a release fade. Easing is one of three keywords: ease-enter, ease-exit and linear. ease-in-out is dropped with its text quoted. Colours interpolate in OKLCH. Animated values live in an overlay applied at paint and are never written back into the computed style, so a transition retargeted halfway starts from where it is. transition does not inherit.

@starting-style gives an element the style it transitions from on its first frame (ADR-0352). @keyframes names a sequence animation runs under the same whitelist (ADR-0353). Under reduced motion every transition collapses to zero and keyframe animations do not run. The desktop’s setting is read at start-up, and -Dgoldberry.motion.reduced=reduce or =full overrides it.

@media is parsed and not evaluated. A theme is chosen by swapping a stylesheet, not by a query.

Cursor

button    { cursor: pointer }
.splitter { cursor: ew-resize }
canvas    { cursor: crosshair }

default, pointer, text, move, wait, progress, crosshair, not-allowed, ew-resize, ns-resize, nesw-resize, nwse-resize, grab and grabbing. The last two fall back to move, because no platform has a system cursor for them. The cursor rides on the painted box and inherits down the stack of rectangles under the pointer, not the element tree (ADR-0057).

Custom properties and var()

:root        { --gb-accent: #b48ead; --gb-scroll-line: 40px }
#revenue     { --gb-chart-1: #b48ead }
.hint        { color: var(--gb-text-placeholder, #4c566a) }

A custom property is declared on any node and inherits. var(--name) reads it, with an optional fallback after a comma. Every colour a control draws comes through a --gb-* token, so an application rule that redefines one on :root restyles every control that reads it, and one that redefines it on #revenue restyles one chart. The tokens that exist are listed in The design system.

Inheritance

color, the font properties, line-height, white-space, text-align and text-decoration pass down the element tree. The layout properties, background, opacity, transform, transition and the border do not (ADR-0066).

ComputedStyle.INITIAL has black text on purpose, so a window with no stylesheet looks like one. Set color on your root, as the showcase does with #root { color: var(--gb-text) }. A control that says nothing still reads right because controls.css sets color on it.

Restyle versus repaint

A repaint redraws what changed. A restyle throws every resolved style away, re-reads Application.stylesheets() and rebuilds the renderer. The second is much more expensive, and almost nothing needs it: a theme and a density, and very little else.

@Bind(value = "app.theme", restyle = true) private String themeName = "dark";

A field declared restyle = true asks for a restyle when it is assigned, and the toolkit calls Host.restyle() for you (ADR-0133). Everything else that changes a value asks for a repaint by itself (ADR-0128). An application outside the model system calls host.restyle() directly.

A restyle is not a full recompute of every node. Each element caches its resolved style and re-resolves only when the resolver changed, when the style it inherits changed, or when a pseudo-class or a rebuild invalidated its subtree (ADR-0070).

Themes, density and scrollbars

@Override public List<Stylesheet> stylesheets() {
    var sheets = new ArrayList<>(Controls.stylesheets(
            settings.theme(),            // Theme.NORD_LIGHT or Theme.NORD_DARK
            settings.density(),          // Density.REGULAR or Density.COMPACT
            settings.scrollbars()
    ));     // Scrollbars.OVERLAY or Scrollbars.ALWAYS
    sheets.add(styles);
    return sheets;
}

Controls.stylesheets(theme), (theme, density) and (theme, density, scrollbars) return the base sheet, the theme, and whatever the other two add, in cascade order. Theme.NORD_LIGHT and Theme.NORD_DARK are the two themes that ship, and a theme is a custom-property layer: the base rules never name a colour.

Density.COMPACT is a handful of tokens on :root, and every control is 28 tall instead of 32 with nothing in your tree mentioning a height. Density.REGULAR ships no stylesheet at all, because regular is what the toolkit already is (ADR-0074). Scrollbars.ALWAYS reserves a 12 px gutter beside scrolling content (ADR-0364).

Following the desktop

@Override public void start(Host host) {
    host.systemTheme().ifPresent(this::follow);
    host.onSystemThemeChanged(this::follow);
}

private void follow(SystemTheme theme) {
    actions.pickTheme(theme == SystemTheme.DARK ? "dark" : "light");   // a restyle = true field
}

host.systemTheme() answers LIGHT, DARK, or empty where the desktop has no such setting, and empty is a different answer from light (ADR-0322). The toolkit chooses nothing with the answer. A Linux build without the D-Bus headers cannot ask at all, and says so through Goldberry.capabilities().contains(Capability.SYSTEM_THEME) (ADR-0325).

Text scale

The renderer scales text between 90% and 150% without scaling the boxes, which is the condition every control is built to survive (ADR-0267). The switch is WidgetRenderer.textScale(double), on the renderer an application builds itself. The launcher does not expose it yet.

Read more