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

Perry widgets accept an inline style: { ... } object that maps to each platform’s native styling APIs. The same shape works on every Widget constructor — Button, Text, Toggle, Slider, VStack/HStack, and friends — so cross-platform styling code stays the same regardless of target.

Pass a StyleProps object as the trailing argument to any widget constructor. Codegen destructures the literal at HIR time into a sequence of native setter calls, so the runtime shape is the same as hand-writing the imperative pattern below — but the source is much shorter:

const card = Button("Save", () => {
    console.log("saved")
}, {
    backgroundColor: { r: 0.231, g: 0.510, b: 0.965, a: 1.0 },
    borderColor: { r: 0.0, g: 0.0, b: 0.0, a: 0.1 },
    borderWidth: 1,
    borderRadius: 8,
    padding: 12,
    opacity: 0.95,
    shadow: {
        color: { r: 0.0, g: 0.0, b: 0.0, a: 0.25 },
        blur: 12,
        offsetX: 0,
        offsetY: 4,
    },
    tooltip: "Save the current document",
    enabled: true,
})

The style arg is optional; widgets without it look identical to calls before this API existed. See docs/examples/ui/styling/button_inline_style.ts for the full file.

What style accepts

PropTypeMaps to
backgroundColorstring | PerryColorwidgetSetBackgroundColor
colorstring | PerryColortextSetColor / buttonSetTextColor
borderColorstring | PerryColorwidgetSetBorderColor
borderWidthnumberwidgetSetBorderWidth
borderRadiusnumbersetCornerRadius
paddingnumber | { top, right, bottom, left }widgetSetEdgeInsets
opacitynumber (0..=1)widgetSetOpacity
shadow{ color, blur, offsetX, offsetY }widgetSetShadow
textDecoration"none" | "underline" | "strikethrough"textSetDecoration
gradient{ angle, stops: [c1, c2] }widgetSetBackgroundGradient
fontSize, fontWeight, fontFamilynumber / stringtextSetFont*
tooltipstringwidgetSetTooltip
hiddenbooleanwidgetSetHidden
enabledbooleanwidgetSetEnabled

Color values

Color props accept four interchangeable shapes:

backgroundColor: "#3B82F6"                                   // hex 6/8
backgroundColor: "#3B82F6FF"                                 // hex with alpha
backgroundColor: "blue"                                      // named color
backgroundColor: { r: 0.231, g: 0.510, b: 0.965, a: 1.0 }   // PerryColor object
backgroundColor: themeColor                                  // runtime variable

Named colors: white, black, red, green, blue, yellow, cyan, magenta, gray / grey, transparent. Hex forms supported: #RGB, #RGBA, #RRGGBB, #RRGGBBAA.

Literals (the first four forms) compile-time-fold into 4 baked-in float arguments — zero runtime cost. Runtime variables resolve through js_color_parse_channel (a small CSS color parser in perry-runtime) so backgroundColor: someStringVar works the same as the literal form.

Padding shapes

A single number applies to all four sides; an object picks per-side:

padding: 12                                       // all four sides 12
padding: { top: 8, right: 16, bottom: 8, left: 16 }  // per-side

Missing sides default to 0.

Container styling

VStack and HStack accept style after the children array:

// VStack with explicit spacing AND inline style — children + style.
const card = VStack(8, [
    Text("Heading"),
    Text("Subtitle"),
    Button("Action", () => { console.log("clicked") }),
], {
    backgroundColor: { r: 0.96, g: 0.97, b: 0.99, a: 1.0 },
    borderRadius: 12,
    padding: 16,
    shadow: {
        color: { r: 0.0, g: 0.0, b: 0.0, a: 0.1 },
        blur: 8,
        offsetY: 2,
    },
})

// HStack with no explicit spacing (children-array first form) + style.
const toolbar = HStack([
    Text("Left"),
    Text("Right"),
], {
    backgroundColor: { r: 0.2, g: 0.2, b: 0.2, a: 1.0 },
    padding: { top: 8, right: 16, bottom: 8, left: 16 },
    borderRadius: 6,
})

Both shapes work — VStack(children, style?) and VStack(spacing, children, style?).

Coming from CSS

If you’re coming from web, the conceptual mapping is:

CSSPerry inline style
display: flex; flex-direction: columnVStack(spacing, [...])
display: flex; flex-direction: rowHStack(spacing, [...])
width: 100%widgetMatchParentWidth(widget)
padding: 10px 20pxpadding: { top: 10, right: 20, bottom: 10, left: 20 }
gap: 16pxVStack(16, [...]) — first argument is the gap
CSS variables / design tokensperry-styling package
opacity: 0.5opacity: 0.5
border-radius: 8pxborderRadius: 8
background: #3B82F6backgroundColor: "#3B82F6"
box-shadow: 0 4px 12px rgba(0,0,0,0.25)shadow: { color: "#0004", blur: 12, offsetY: 4 }
text-decoration: underlinetextDecoration: "underline"

See Layout for full details on alignment, distribution, overlays, and split views.

Imperative API (underlying)

The inline style object lowers to the same FFI calls as Perry’s imperative free-function setters: widgetSet*, textSet*, buttonSet*. They take the widget handle as the first argument and remain available for cases where you want fine-grained control or need to mutate styles after creation. Colors here are RGBA floats in [0.0, 1.0] (divide each hex byte by 255 — 0xFF3B30(1.0, 0.231, 0.188, 1.0)).

Every snippet below is excerpted from docs/examples/ui/styling/snippets.ts, which CI compiles and runs on every PR — so the API drawn here is always the API the compiler accepts.

import {
    App,
    VStack, VStackWithInsets, HStack, Spacer,
    Text, Button,
    textSetColor, textSetFontSize, textSetFontFamily, textSetFontWeight,
    setCornerRadius, setPadding,
    widgetAddChild,
    widgetSetBackgroundColor, widgetSetBackgroundGradient,
    widgetSetBorderColor, widgetSetBorderWidth,
    widgetSetEdgeInsets,
    widgetSetWidth, widgetSetHeight, widgetMatchParentWidth,
    widgetSetOpacity,
    widgetSetControlSize,
    widgetSetTooltip,
    widgetSetEnabled,
} from "perry/ui"

Colors

const colored = Text("Colored text")
textSetColor(colored, 1.0, 0.0, 0.0, 1.0)              // r, g, b, a in [0,1]
widgetSetBackgroundColor(colored, 0.94, 0.94, 0.94, 1.0)

Fonts

const font = Text("Styled text")
textSetFontSize(font, 24)                  // Font size in points
textSetFontFamily(font, "Menlo")           // Font family name
textSetFontWeight(font, 24, 700)           // Re-set size + weight together

Use "monospaced" for the system monospaced font.

Corner Radius

const rounded = Button("Rounded", () => {})
setCornerRadius(rounded, 12)

Borders

const bordered = VStack(0, [])
widgetSetBorderColor(bordered, 0.8, 0.8, 0.8, 1.0)
widgetSetBorderWidth(bordered, 1)

Padding and Insets

const padded = VStack(8, [Text("Padded content")])
// Both names accept (widget, top, left, bottom, right):
setPadding(padded, 16, 16, 16, 16)
widgetSetEdgeInsets(padded, 10, 20, 10, 20)

Sizing

const sized = VStack(0, [])
widgetSetWidth(sized, 300)
widgetSetHeight(sized, 200)
widgetMatchParentWidth(sized) // expand to fill parent's width

Opacity

const dim = Text("Semi-transparent")
widgetSetOpacity(dim, 0.5) // 0.0 to 1.0

Background Gradient

const grad = VStack(0, [])
// Two RGBA stops + angle (degrees, 0 = top-to-bottom).
widgetSetBackgroundGradient(grad,
    1.0, 0.0, 0.0, 1.0,   // start (red)
    0.0, 0.0, 1.0, 1.0,   // end   (blue)
    0,                    // angle
)

Control Size

const small = Button("Small", () => {})
widgetSetControlSize(small, 0) // 0=mini, 1=small, 2=regular, 3=large

macOS: Maps to NSControl.ControlSize. Other platforms may interpret differently.

Tooltips

const tip = Button("Hover me", () => {})
widgetSetTooltip(tip, "Click to perform action")

macOS/Windows/Linux: Native tooltips. iOS/Android: No tooltip support. Web: HTML title attribute.

Enabled/Disabled

const submit = Button("Submit", () => {})
widgetSetEnabled(submit, 0)  // 0 = disabled, 1 = enabled

Complete Imperative Example

// demonstrates: a styled counter card using the real free-function API
// docs: docs/src/ui/styling.md
// platforms: macos, linux, windows
// targets: ios-simulator, web, wasm

import {
    App,
    Text,
    Button,
    VStack,
    HStack,
    State,
    Spacer,
    textSetFontSize,
    textSetFontFamily,
    textSetColor,
    widgetSetBackgroundColor,
    widgetSetEdgeInsets,
    setCornerRadius,
} from "perry/ui"

// Note: widgetSetBorderColor / widgetSetBorderWidth are macOS/iOS/Windows
// only — perry-ui-gtk4 doesn't export them (GTK4 borders are CSS-driven).
// Omitted from this demo so it compiles everywhere.

const count = State(0)

const title = Text("Counter")
textSetFontSize(title, 28)
textSetColor(title, 0.1, 0.1, 0.1, 1.0)

const display = Text(`${count.value}`)
textSetFontSize(display, 48)
textSetFontFamily(display, "monospaced")
textSetColor(display, 0.0, 0.478, 1.0, 1.0)

const decBtn = Button("-", () => count.set(count.value - 1))
setCornerRadius(decBtn, 20)
widgetSetBackgroundColor(decBtn, 1.0, 0.231, 0.188, 1.0)

const incBtn = Button("+", () => count.set(count.value + 1))
setCornerRadius(incBtn, 20)
widgetSetBackgroundColor(incBtn, 0.204, 0.78, 0.349, 1.0)

const controls = HStack(8, [decBtn, Spacer(), incBtn])
widgetSetEdgeInsets(controls, 20, 20, 20, 20)

const container = VStack(16, [title, display, controls])
widgetSetEdgeInsets(container, 40, 40, 40, 40)
setCornerRadius(container, 16)
widgetSetBackgroundColor(container, 1.0, 1.0, 1.0, 1.0)

App({
    title: "Styled App",
    width: 400,
    height: 300,
    body: container,
})

Composing Styles (imperative helper functions)

Reduce repetition by creating helper functions:

function card(children: number[]): number {
  const c = VStackWithInsets(12, 16, 16, 16, 16)
  setCornerRadius(c, 12)
  widgetSetBackgroundColor(c, 1.0, 1.0, 1.0, 1.0)
  widgetSetBorderColor(c, 0.9, 0.9, 0.9, 1.0)
  widgetSetBorderWidth(c, 1)
  for (const child of children) widgetAddChild(c, child)
  return c
}

For larger apps, use the perry-styling package to define design tokens in JSON and generate a typed theme file. See Theming for the full workflow.

Platform support

Per-prop, per-platform support is tracked in the styling matrix — auto-generated from crates/perry-ui/src/styling_matrix.rs and CI-checked against each backend’s lib.rs exports on every PR.

Current state (issue #185):

PlatformWiredStubMissing
macOS / iOS / tvOS / visionOS / watchOS / Android / Web43/4300
GTK4 (Linux)39/4304
Windows38/4350
  • GTK4 has 4 styling props (widget.on_click, button.content_tint_color, button.image_position, stack.detaches_hidden) that need a Linux contributor — tracked in issue #202. Inline style: {...} calls referencing only the wired props compile and run cleanly today; the missing props silently no-op until that issue lands.
  • Windows has 5 props in a “deferred-paint family” (shadow, opacity, border_color, border_width, text.decoration) where the FFI symbol exists and stores the requested params, but a custom WM_PAINT rendering pass is needed to make them visible — tracked in issue #210. User code authoring inline styles compiles and links cleanly on Windows; the visual rendering catches up when that issue lands.

Next Steps

  • Widgets — All available widgets
  • Layout — Layout containers
  • Animation — Animate style changes
  • Theming — Design tokens via the perry-styling package