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.
Inline style — recommended
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
| Prop | Type | Maps to |
|---|---|---|
backgroundColor | string | PerryColor | widgetSetBackgroundColor |
color | string | PerryColor | textSetColor / buttonSetTextColor |
borderColor | string | PerryColor | widgetSetBorderColor |
borderWidth | number | widgetSetBorderWidth |
borderRadius | number | setCornerRadius |
padding | number | { top, right, bottom, left } | widgetSetEdgeInsets |
opacity | number (0..=1) | widgetSetOpacity |
shadow | { color, blur, offsetX, offsetY } | widgetSetShadow |
textDecoration | "none" | "underline" | "strikethrough" | textSetDecoration |
gradient | { angle, stops: [c1, c2] } | widgetSetBackgroundGradient |
fontSize, fontWeight, fontFamily | number / string | textSetFont* |
tooltip | string | widgetSetTooltip |
hidden | boolean | widgetSetHidden |
enabled | boolean | widgetSetEnabled |
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:
| CSS | Perry inline style |
|---|---|
display: flex; flex-direction: column | VStack(spacing, [...]) |
display: flex; flex-direction: row | HStack(spacing, [...]) |
width: 100% | widgetMatchParentWidth(widget) |
padding: 10px 20px | padding: { top: 10, right: 20, bottom: 10, left: 20 } |
gap: 16px | VStack(16, [...]) — first argument is the gap |
| CSS variables / design tokens | perry-styling package |
opacity: 0.5 | opacity: 0.5 |
border-radius: 8px | borderRadius: 8 |
background: #3B82F6 | backgroundColor: "#3B82F6" |
box-shadow: 0 4px 12px rgba(0,0,0,0.25) | shadow: { color: "#0004", blur: 12, offsetY: 4 } |
text-decoration: underline | textDecoration: "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
titleattribute.
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):
| Platform | Wired | Stub | Missing |
|---|---|---|---|
| macOS / iOS / tvOS / visionOS / watchOS / Android / Web | 43/43 | 0 | 0 |
| GTK4 (Linux) | 39/43 | 0 | 4 |
| Windows | 38/43 | 5 | 0 |
- 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. Inlinestyle: {...}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 customWM_PAINTrendering 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.