Asuka
Components

Guides

Components

Text, buttons, inputs, images, lists, video, web views and drawing.

A component is a Go function that returns a ui.Node. This page covers the views package ui gives you, grouped by what they do, and the packages next to it for Markdown and SVG. Each one is the platform's own widget where the platform has one: UIKit on iOS, Android Views and Material 3 on Android, AppKit on macOS, Win32's controls drawn as Windows 11's (Fluent) on Windows, GTK 4 and libadwaita on Linux, the page's elements in a browser. How views are sized and placed is in Layout; screens, tabs and sheets are in Navigation.

Most props that can change take a signal.Accessor: pass a signal, a signal.Func, or ui.Val(v) for a value that never changes. A control that the user changes (a field, a toggle) takes a *signal.Signal instead, and writes it. See Concepts.

Each platform's widgets

Component iOS Android macOS Windows Linux Web
Text UILabel TextView NSTextField (a label) drawn by the host's view GtkLabel the page's text
Button UIButton MaterialButton NSButton BUTTON, drawn as Fluent's GtkButton <button>
TextField UITextField; UITextView multiline EditText NSTextField, NSSecureTextField; NSTextView multiline EDIT in a frame drawn as Fluent's GtkEntry, GtkPasswordEntry; GtkTextView multiline <input>; <textarea> multiline
Toggle UISwitch MaterialSwitch NSSwitch a BUTTON check box drawn as a toggle switch GtkSwitch <input type="checkbox" switch>
Slider UISlider Material Slider NSSlider a trackbar GtkScale <input type="range">
Segmented UISegmentedControl Material segmented buttons NSSegmentedControl drawn, as the Windows Community Toolkit's linked GtkToggleButtons buttons in a radio group
Stepper UIStepper two Material icon buttons NSStepper two buttons linked − and + buttons two buttons
Wheel UIPickerView NumberPicker NSPopUpButton a drop-down COMBOBOX GtkDropDown <select>
ColorPicker UIColorWell a swatch and a Material dialog NSColorWell and the color panel a swatch and the color dialog (ChooseColor) GtkColorDialogButton <input type="color">
DatePicker UIDatePicker buttons opening Material's pickers NSDatePicker the date and time picker (SysDateTimePick32) a button opening GtkCalendar and spin buttons <input type="date">, time, datetime-local
Progress UIProgressView LinearProgressIndicator NSProgressIndicator drawn as Fluent's GtkProgressBar <progress>
Activity UIActivityIndicatorView CircularProgressIndicator NSProgressIndicator, spinning Fluent's progress ring, drawn AdwSpinner (GtkSpinner before libadwaita 1.6) a spinner, drawn
Image UIImageView ImageView NSImageView drawn with GDI+ drawn from GDK textures <img>
Symbol SF Symbols Material Symbols SF Symbols Segoe Fluent Icons the icon theme's, else Material Symbols Material Symbols
Video AVPlayerViewController Media3's player AVPlayerView a <video> in WebView2 GtkVideo (GStreamer) <video>
WebView WKWebView WebView WKWebView WebView2 WebKitWebView (WebKitGTK 6.0) <iframe>
Scroll UIScrollView NestedScrollView NSScrollView the host's view, Fluent's scroll indicator GtkScrolledWindow a scrolling element
List UICollectionView RecyclerView rows the host recycles, in an NSScrollView rows the host recycles rows the host recycles, in a GtkScrolledWindow rows the host recycles

The sections below say where a platform behaves differently.

Text

Text and StaticText

ui.Text shows a line or paragraph of text. It is the platform's label, and wraps by default; Style.Lines limits how many lines it takes, and cuts the rest with an ellipsis. ui.StaticText is a Text whose text never changes.

count := signal.New(0)
label := signal.Func(func() string { return fmt.Sprintf("%d unread", count.Get()) })
return ui.Column(ui.Style{Gap: 8},
	ui.StaticText("Inbox", ui.Style{Font: ui.Title2.Weighted(ui.Bold)}),
	ui.Text(label, ui.Style{Color: ui.SecondaryLabel}),
	ui.StaticText("A long description that stops after two lines.", ui.Style{Lines: 2}),
)

The style's Font, Color, TextAlign and Lines are the text's. Text styles (ui.Body, ui.Headline, ui.Footnote...) follow the text size the user picked.

RichText

ui.RichText shows styled text: a ui.Rich, a list of spans. A "\n" in a span starts a new paragraph. A span's fields left zero come from the RichText's style.

ui.RichText(ui.RichTextProps{
	Text: ui.Val(ui.Rich{
		ui.Strong("asuka"), ui.Plain(" draws "), ui.Em("native"), ui.Plain(" views. Run "),
		ui.Code("asuka dev"), ui.Plain(", or read "), ui.Link("the docs", "https://example.com/docs"),
		ui.Plain(".\nA second paragraph, "), ui.Plain("in red").Colored(ui.Red), ui.Plain("."),
	}),
	OnLink: ui.OpenURL,
})
  • Plain, Strong, Em, Code and Link make spans. Colored, Highlighted and Weighted return a span with a color, a background or a weight.
  • A ui.Span has the rest: Font, Italic, Mono, Underline, Strike, Background (drawn on each line the span covers, with rounded corners).
  • A tapped link calls OnLink with its URL. Without OnLink, links do nothing.
  • Role: ui.RoleHeader makes it a heading to screen readers.

On iOS and Android each paragraph is typeset by the platform's engine (CoreText, StaticLayout) and cached by its content and width: appending to a long text, a reply streaming in, typesets only the paragraph that changed. On macOS it's a read-only NSTextView (TextKit), on Windows a read-only rich edit (RICHEDIT50W), on Linux a GtkLabel styled with Pango's attributes, and in a browser spans in paragraphs. examples/richtext streams one.

For plain text, use Text: it is the platform's label, with its native behavior.

Inline views. ui.Embed puts a component in the text: it sits on the baseline and wraps with the words. Its key keeps the same component, and its state, from one Rich to the next.

ui.RichText(ui.RichTextProps{Text: ui.Val(ui.Rich{
	ui.Plain("Status: "),
	ui.Embed("badge", func() ui.Node {
		return ui.View(ui.ViewProps{Style: ui.Style{Background: ui.Green, CornerRadius: 6, Padding: ui.Symmetric(2, 6)}},
			ui.StaticText("live", ui.Style{Font: ui.Caption, Color: ui.RGB(255, 255, 255)}))
	}),
	ui.Plain(" since this morning."),
})})

Selectable text

ui.Selectable makes the RichText views inside it selectable, as one text across all of them: a long press selects a word, the platform's handles extend the selection, and its edit menu copies it. With a mouse, a drag selects and the platform's copy shortcut copies. Linux and the web select across the texts as one too; on macOS and Windows each text selects on its own.

ui.Selectable(ui.SelectableProps{Style: ui.Style{Gap: 12}},
	ui.RichText(ui.RichTextProps{Text: ui.Val(question), Role: ui.RoleHeader}),
	ui.RichText(ui.RichTextProps{Text: ui.Val(answer)}),
)

Text is not selectable; use a RichText in a Selectable. To copy something without a selection, ui.Copy(text) puts it on the clipboard.

Markdown

Package asuka/markdown shows Markdown as native views, built from RichText. It parses as text is appended: only the block being written parses again, and only its views update.

markdown.View(markdown.Props{Text: reply, Streaming: writing})

Streaming says the text is still being written, so a half-written **bold shows as bold rather than as asterisks. The view is one selection by default (NotSelectable turns that off); links open with ui.OpenURL unless you give OnLink; Theme changes fonts, colors and spacing; Code shows code blocks your own way. It reads CommonMark and GitHub's extensions. See examples/markdown.

Buttons

ui.Button is the platform's button: a UIButton configuration on iOS, a Material button on Android, an NSButton on macOS, a BUTTON drawn as Fluent's on Windows, a GtkButton on Linux, a <button> in a browser.

saving := signal.New(false)
return ui.Row(ui.Style{Gap: 12, Wrap: true},
	ui.Button(ui.ButtonProps{Title: ui.Val("Save"), Variant: ui.ButtonFilled, Disabled: saving,
		OnPress: func() { saving.Set(true) }}),
	ui.Button(ui.ButtonProps{Title: ui.Val("Compose"), Icon: ui.IconCompose, Variant: ui.ButtonTinted}),
	ui.Button(ui.ButtonProps{Icon: ui.IconTrash, Label: ui.Val("Delete"), Variant: ui.ButtonGray}),
)
  • Title is its text; Icon a symbol before it (see Symbols). A button with an icon and no title is the icon alone.
  • Label is what VoiceOver and TalkBack read, the title by default. Give an icon-only button one.
  • Tooltip is a short text shown while a pointer rests on it: an iPad's pointer (UIToolTipInteraction), a mouse on Android, where a long press shows it too (tooltipText), and on macOS, Windows, Linux and the web (the element's title). Give an icon-only button one, its name. ViewProps.Tooltip is the same for any view. A touch screen alone never shows it on iOS, so it can't be the only place something is said.
  • Disabled disables it, the platform's way.
  • Style.Font and Style.Color change its text and tint.

Variant is its look:

Variant iOS Android macOS Windows Linux Web
ButtonPlain (default) plain text button borderless, in the accent color subtle, in the accent color flat, in the accent color plain
ButtonFilled filled filled button push button in the accent color accent button suggested action filled
ButtonTinted tinted tonal button push button, its title in the accent color a wash of the accent a wash of the accent tinted
ButtonGray gray tonal button push button standard button standard button gray
ButtonBordered bordered outlined button push button standard button standard button outlined
ButtonGlass Liquid Glass (glass(), iOS 26; gray before) tonal button as ButtonGray as ButtonGray standard button as ButtonGray
ButtonGlassProminent tinted Liquid Glass (prominentGlass(), iOS 26; filled before) filled button as ButtonFilled as ButtonFilled suggested action as ButtonFilled

The glass buttons are capsules, and a glass button with an icon alone is a circle: a sheet's close button, as iOS 26's own.

A button with Menu shows its actions when it's tapped, instead of calling OnPress (a UIMenu on iOS, a PopupMenu on Android, an NSMenu on macOS, a pop-up menu on Windows, a GtkPopoverMenu on Linux, a popover in a browser). Each ui.Action has a Title, an optional Icon beside it, a Role, and OnPress. ui.ActionDestructive shows it in red.

ui.Button(ui.ButtonProps{Icon: ui.IconMore, Label: ui.Val("More"), Menu: []ui.Action{
	{Title: ui.Val("Copy"), Icon: ui.IconCopy, OnPress: func() { ui.Copy("https://go.dev") }},
	{Title: ui.Val("Share"), Icon: ui.IconShare, OnPress: func() { ui.Share("https://go.dev") }},
	{Title: ui.Val("Delete"), Icon: ui.IconTrash, Role: ui.ActionDestructive, OnPress: func() {}},
}})

ui.Menu puts a menu on any content. With LongPress it's a context menu, shown on a long press; with a mouse (macOS, Windows, Linux, the web), on a secondary click:

ui.Menu(ui.MenuProps{LongPress: true, Actions: []ui.Action{
	{Title: ui.Val("Reply"), Icon: ui.IconMessage},
	{Title: ui.Val("Delete"), Role: ui.ActionDestructive},
}, Style: ui.Style{Padding: ui.All(16), Background: ui.SecondaryBackground, CornerRadius: 12}},
	ui.StaticText("Long-press for a menu"),
)

Text fields

ui.TextField is a text input. Value is its text: typing writes the signal, and writing the signal from code replaces the text.

email := signal.New("")
password := signal.New("")
var pw ui.Node
pw = ui.TextField(ui.TextFieldProps{Value: password, Placeholder: ui.Val("Password"),
	Secure: true, ContentType: "password", ReturnKey: ui.ReturnGo,
	OnSubmit: func() { ui.Blur(pw) }})
em := ui.TextField(ui.TextFieldProps{Value: email, Placeholder: ui.Val("Email"),
	Keyboard: ui.KeyboardEmail, ContentType: "email", Autocapitalize: ui.CapitalizeNone,
	Autocorrect: ui.AutocorrectNo, ReturnKey: ui.ReturnNext, OnSubmit: func() { ui.Focus(pw) }})
return ui.Column(ui.Style{Gap: 12}, em, pw)
  • Secure hides what's typed, for passwords.
  • Keyboard: KeyboardDefault, KeyboardNumber, KeyboardDecimal, KeyboardEmail, KeyboardURL, KeyboardPhone.
  • ReturnKey is the return key's label: ReturnDefault, ReturnDone, ReturnGo, ReturnNext, ReturnSearch, ReturnSend. Pressing it calls OnSubmit.
  • ContentType says what it holds, for the system's AutoFill: ui.ContentUsername, ContentPassword, ContentNewPassword (the platform offers a strong password), ContentOneTimeCode (the code a message just brought shows above the keyboard: UIKit's oneTimeCode, Android's SMS code hint), ContentEmail, ContentPhone, ContentName, ContentAddress, ContentPostalCode, ContentCardNumber... Each host names it as its platform does.
  • Autocapitalize: CapitalizeSentences (the default), CapitalizeNone, CapitalizeWords, CapitalizeCharacters. Autocorrect: AutocorrectDefault, AutocorrectYes, AutocorrectNo.
  • OnFocus and OnBlur follow the keyboard. ui.Focus(field) asks for it, ui.Blur(field) dismisses it.

A desktop has no on-screen keyboard to change, so Keyboard and ReturnKey mostly don't show there; Return still calls OnSubmit. On macOS ContentType is the field's AutoFill content type. On Windows KeyboardNumber takes digits only. On Linux Keyboard, ContentType, Autocapitalize and Autocorrect tell the input method what the field takes (GTK's input purpose and hints). In a browser they're the input's inputmode, enterkeyhint, autocomplete, autocapitalize and spellcheck, which a phone's keyboard follows.

Lines of text. Multiline makes it a text area, for a message or a comment: return starts a new line instead of calling OnSubmit, and it grows with its text up to Style.Lines lines (0: no limit), then scrolls.

message := signal.New("")
return ui.Row(ui.Style{Gap: 8, Align: ui.AlignEnd},
	ui.TextField(ui.TextFieldProps{Value: message, Placeholder: ui.Val("Message"), Multiline: true,
		Style: ui.Style{Grow: 1, Lines: 5}}),
	ui.Button(ui.ButtonProps{Icon: ui.IconSend, Label: ui.Val("Send"), Variant: ui.ButtonFilled,
		Disabled: signal.Func(func() bool { return message.Get() == "" }),
		OnPress:  func() { message.Set("") }}),
)

The text alone. Plain drops the field's border, background and inset: its text and placeholder, for a field in a card or a form of your own, as Notes' and Reminders' are.

ui.TextField(ui.TextFieldProps{Value: note, Placeholder: ui.Val("Note"), Multiline: true, Plain: true})

A SafeArea is padded by the keyboard, so a field at the bottom of one stays above it.

Controls

Each control takes the signal it changes, and a Disabled accessor. None of them has a label of its own: put a Text beside it.

return ui.Column(ui.Style{Gap: 16},
	ui.Row(ui.Style{Align: ui.AlignCenter},
		ui.StaticText("Wi-Fi", ui.Style{Grow: 1}),
		ui.Toggle(ui.ToggleProps{Value: wifi})),
	ui.Slider(ui.SliderProps{Value: volume}),
	ui.Segmented(ui.SegmentedProps{Segments: ui.Val([]string{"Day", "Week", "Month"}), Selected: period}),
	ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
		ui.Text(signal.Func(func() string { return fmt.Sprintf("%.0f cups", cups.Get()) }), ui.Style{Grow: 1}),
		ui.Stepper(ui.StepperProps{Value: cups, Min: 1, Max: 8})),
	ui.Wheel(ui.WheelProps{Items: ui.Val(sizes), Selected: size}),
	ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
		ui.StaticText("Tint", ui.Style{Grow: 1}),
		ui.ColorPicker(ui.ColorPickerProps{Value: tint, Alpha: true, Title: ui.Val("Tint")})),
	ui.Row(ui.Style{Gap: 12, Align: ui.AlignCenter},
		ui.StaticText("Starts", ui.Style{Grow: 1}),
		ui.DatePicker(ui.DatePickerProps{Value: when, Mode: ui.DateAndTime, Min: time.Now()})),
)
  • Toggle is a switch, *signal.Signal[bool]. With Checkbox it's a checkbox: Android's MaterialCheckBox, a Mac's and Windows' check box, a GtkCheckButton, a browser's check box, and on iOS, which has none, a circle that fills with a checkmark, as Reminders' does.

  • Slider picks a float64 from Min to Max; with both 0, from 0 to 1.

  • Segmented is one of a few options side by side, Selected the index: UIKit's segmented control, Material's segmented buttons. Windows has none: the host draws one, as the Windows Community Toolkit's Segmented.

  • Stepper steps a float64 with minus and plus, by Step (default 1), from Min to Max (both 0: 0 to 100). It's a UIStepper on iOS and an NSStepper on macOS; elsewhere it's two buttons (Material icon buttons on Android, linked buttons on Linux, as GNOME's spin buttons show them).

  • Wheel picks one of many Items on a spinning wheel: UIPickerView, Android's NumberPicker. The desktop and the web have no wheel: it's a pop-up menu of the items there (NSPopUpButton, a drop-down combo box, GtkDropDown, <select>).

  • ColorPicker is a swatch that opens the platform's picker, writing a ui.Color. Alpha lets the user pick an opacity, Title names the picker. On iOS it's UIColorWell and the system's picker; Android has none, so it's a Material dialog of a palette, with an opacity slider for Alpha. On macOS it's an NSColorWell opening the color panel, on Linux a GtkColorDialogButton, in a browser <input type="color">. Windows' color dialog has no opacity, so Alpha does nothing there.

  • DatePicker picks a time.Time, shown in the device's time zone. Mode is ui.DateOnly (the default), ui.TimeOnly or ui.DateAndTime; Min and Max bound it. A zero Value shows the current time.

    Compact (default) Inline
    iOS UIKit's compact date picker the calendar and time in place, for a sheet of its own
    Android buttons showing the date and time, opening Material's pickers the same buttons
    macOS NSDatePicker's text field and stepper its calendar and clock
    Windows the date and time picker, its calendar dropping down a month calendar, a time picker under it for DateAndTime (TimeOnly stays compact)
    Linux a button opening a popover of GTK's calendar and hour and minute spin buttons the calendar and spin buttons in place
    Web <input type="date">, "time", "datetime-local" the same input

Progress and activity

ui.Progress is a progress bar: its value from 0 to 1, or nil when how far is unknown. Its style's Color is its fill. ui.Activity is a spinner.

return ui.Column(ui.Style{Gap: 12},
	ui.Progress(done),
	ui.Progress(nil),
	ui.Activity(),
)

Images

ui.Image shows a picture. The platform loads and decodes it off the main thread, and caches it in memory. iOS and Android keep an image that came over http(s) on disk too, across launches; the desktop hosts keep it in memory only, and a browser in its own cache. Windows decodes PNG, JPEG and GIF; Linux those and what GDK's loaders read.

return ui.Column(ui.Style{Gap: 12},
	ui.Image(ui.ImageProps{Source: ui.Val("logo.png"), Style: ui.Style{Width: ui.Pt(64)}}),
	ui.Image(ui.ImageProps{
		Source:      ui.Val("https://picsum.photos/id/1015/600/400"),
		Placeholder: "placeholder.png",
		Mode:        ui.ImageFill,
		Label:       ui.Val("A river in a valley"),
		Style:       ui.Style{AspectRatio: 3.0 / 2, CornerRadius: 12, Clip: true},
	}),
)
  • Source is an http(s) URL, a path in the app's assets directory ("logo.png" is assets/logo.png), or an absolute path: a file the app wrote, a photo the user picked.
  • Mode is how it fills its box: ImageFit (the default, all of it, letterboxed), ImageFill (covers the box, cropped), ImageStretch (the box's aspect ratio), ImageCenter (its natural size, centered).
  • Placeholder is shown until Source loads, and if it fails. Use an asset, which is there at once. Give the image a size so the placeholder has room.
  • Label describes it to screen readers; without one, the image is decorative.
  • OnLoad gets its natural size once it's loaded; OnError a message when it can't be.

Until it has loaded, an image without a size is empty. Then it takes its natural size, one point per pixel, no wider than the space it has. With one side set (a Width, or Grow in a row) it keeps its aspect ratio; AspectRatio sets one before it loads.

Symbols and icons

ui.Symbol shows a symbol. It's an SF Symbol on iOS and macOS, the Material Symbol for it on Android and the web, the Segoe Fluent Icons glyph for it on Windows (Segoe MDL2 Assets before Windows 11), and on Linux the icon theme's symbolic icon for it (Adwaita's), else the Material Symbol. It's sized as text in its style's Font, so it follows the text size, and colored with its Color (the label color by default).

return ui.Row(ui.Style{Gap: 14, Align: ui.AlignCenter},
	ui.Symbol(ui.IconBell),
	ui.Symbol(ui.IconStar.Filled(), ui.Style{Color: ui.Yellow, Font: ui.Title}),
	ui.Symbol(ui.Icon("archivebox"), ui.Style{Color: ui.SecondaryLabel}),
)

A ui.Icon is an SF Symbol name. Buttons, menu items, bar buttons and tabs take one too. The Icon constants are the common ones: IconHome, IconSearch, IconSettings, IconPlus, IconClose, IconTrash, IconShare, IconMore and about fifty more. icon.Filled() is its filled form ("star.fill").

iOS and macOS draw any SF Symbol. The others draw a curated set: the constants and more, mapped to Material Symbols in platform/android/gen_symbols.go (the web's and Linux's are generated from it), and to Segoe Fluent Icons' glyphs on Windows. A name outside the set shows nothing there (the host logs a warning), so check a name you write as a string, like "archivebox", on each platform you ship. On Linux a name ending in -symbolic is an icon theme's name, used as it is.

SVG icons

For icons of your own, package asuka/svg draws SVG with the platform's vector drawing; nothing is rasterized. svg.MustParse reads the markup (svg.Parse returns an error instead), and svg.View draws it.

var download = svg.MustParse(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>`)

func downloadIcon() ui.Node {
	return svg.View(svg.Props{Icon: download, Color: ui.Val(ui.Accent), Label: ui.Val("Download"),
		Style: ui.Style{Width: ui.Pt(24), Height: ui.Pt(24)}})
}

Color paints what the SVG paints with currentColor, and fills it doesn't give; it's ui.Label, the text color, by default. Without a width or height in its Style, the view is the SVG's own size; with one, the other keeps the aspect ratio. Keep an icon in a file with //go:embed and parse it once, in a package variable.

It reads what icon sets use (Lucide, Material, Heroicons, Feather): paths, basic shapes, groups, use, fills, strokes and transforms. It skips text, gradients (painted with the icon's color), clipping, masks, filters and fill-rule="evenodd".

Video

ui.Video plays a video with the platform's player and its controls: AVPlayerViewController on iOS, Media3's ExoPlayer on Android, AVPlayerView on macOS, a GtkVideo (GStreamer) on Linux, a <video> in a browser. Win32 has no video view, so on Windows it's a <video> in WebView2, with Edge's controls. ui.Play, ui.Pause and ui.Seek drive it.

var v ui.Node
v = ui.Video(ui.VideoProps{
	Source:   ui.Val("https://example.com/clip.mp4"),
	AutoPlay: true, Muted: true, Loop: true,
	Style: ui.Style{CornerRadius: 12},
})
return ui.Column(ui.Style{Gap: 12}, v,
	ui.Button(ui.ButtonProps{Title: ui.Val("Start over"), OnPress: func() { ui.Seek(v, 0); ui.Play(v) }}),
	ui.Button(ui.ButtonProps{Title: ui.Val("Pause"), OnPress: func() { ui.Pause(v) }}),
)
  • Source is an http(s) URL (a file, an HLS stream) or a file in the app's assets.
  • Mode is ui.ImageFit (letterboxed, the default) or ui.ImageFill.
  • HideControls hides the platform's controls: play, the timeline, full screen, and on iOS AirPlay and picture in picture.
  • OnEnd is called each time it plays to its end; OnError if it can't play.

It sizes itself as an Image does: empty until the video's size is known, unless its style gives it one, then keeping the video's aspect ratio. Seek takes seconds from the start.

Web pages

ui.WebView shows a web page in the platform's web view, with its scrolling, zooming and selection: WKWebView on iOS and macOS, Android's WebView, WebView2 (Edge's runtime) on Windows, WebKitGTK 6.0's WebKitWebView on Linux. Without WebKitGTK installed, it shows nothing on Linux and calls OnError once, saying what to install. In a browser it's an <iframe>, which can do all of what follows only for pages of the app's own site; see the web. With no height and no Grow it grows to fill the space it's given.

state := signal.New(ui.WebState{})
var web ui.Node
web = ui.WebView(ui.WebViewProps{
	URL:       ui.Val("https://go.dev"),
	Block:     []string{"doubleclick.net", "/ads/"},
	OnState:   func(s ui.WebState) { state.Set(s) },
	OnMessage: func(m string) { fmt.Println("the page says", m) },
	OnNewWindow: func(url string) {
		ui.OpenURL(url)
	},
})
return ui.Column(ui.Style{Grow: 1},
	ui.Row(ui.Style{Gap: 8, Padding: ui.Symmetric(8, 12), Align: ui.AlignCenter},
		ui.Button(ui.ButtonProps{Icon: ui.IconBack, Label: ui.Val("Back"),
			Disabled: signal.Func(func() bool { return !state.Get().CanGoBack }),
			OnPress:  func() { ui.WebBack(web) }}),
		ui.Text(signal.Func(func() string { return state.Get().Title }), ui.Style{Grow: 1, Lines: 1}),
		ui.Button(ui.ButtonProps{Icon: ui.IconRefresh, Label: ui.Val("Reload"), OnPress: func() { ui.WebReload(web) }}),
	),
	ui.Show(signal.Func(func() bool { return state.Get().Loading }), func() ui.Node {
		return ui.Progress(signal.Func(func() float64 { return float64(state.Get().Progress) }))
	}),
	web,
)

What it shows:

  • URL is an http(s) URL, or a file in the app's assets (its relative links are the assets' too). HTML is markup to show instead.
  • UserAgent is what pages are fetched with, and navigator.userAgent; empty, the platform's.
  • Preload is script run before each page's own, in every frame: to hook fetch, or set globals.
  • Block keeps requests from loading, as an ad blocker does. Each entry is a host, which matches its subdomains too ("doubleclick.net"), or, with a /, a part of the URL ("/ads/").

Preload, UserAgent and Block are set when the view is made.

What it reports:

  • OnLoad gets the URL of each page that has loaded; OnError a message when one couldn't.
  • OnState follows the page, as an address bar does: a ui.WebState with its URL, Title, Loading, Progress (0 to 1), CanGoBack and CanGoForward.
  • OnNewWindow gets the URL the page would open in a new window (window.open, a target="_blank" link). Without it, that page loads in this view.
  • OnDownload gets a response the page can't show, a file to save (a link to a .zip, a Content-Disposition: attachment), as a ui.WebDownload with its URL, MIME, Filename and Size (-1 when unknown). It doesn't download it.
  • OnRequest gets the URL of each request the page makes (scripts, images, media, fetch). Not on iOS and macOS: WKWebView doesn't let an app see them.

ui.WebBack, ui.WebForward, ui.WebReload and ui.WebStop move through its history, load the page again, or stop it loading.

Talking to the page

The page posts to Go with window.asuka.postMessage(text), which calls OnMessage. Go runs script in the page with ui.EvalJS, which returns nothing, or ui.EvalJSResult, which calls back with the value as JSON: the last statement's, or what a returned promise resolves to (null for undefined), or the error it threw.

var web ui.Node
web = ui.WebView(ui.WebViewProps{
	HTML: ui.Val(`<button onclick="asuka.postMessage('tapped')">Tap</button>`),
	OnMessage: func(m string) {
		ui.EvalJSResult(web, "document.title", func(v json.RawMessage, err error) {
			fmt.Println(m, string(v), err)
		})
	},
})

ui.WebCookies(url, done) gets the cookies web views have for a URL, as a Cookie header: what the user signed in to in a page, for your own requests.

examples/downloader uses all of this: a browser with an address bar, a preload script, blocking and downloads.

Shapes

ui.Shape draws an outline, filled, stroked or both. Path makes the outline for the shape's size; it runs again when the size changes, and when signals it reads change. A shape takes the size its style gives it (a width and height, an aspect ratio, Grow); with none, it's empty.

ui.Shape(ui.ShapeProps{
	Path: func(w, h float32) *ui.Path {
		return new(ui.Path).MoveTo(w/2, 0).LineTo(w, h).LineTo(0, h).Close()
	},
	Fill:  ui.Orange,
	Style: ui.Style{Width: ui.Pt(48), Height: ui.Pt(48)},
})

A ui.Path is in points from the shape's top left. Its methods add to it and return it, to chain: MoveTo, LineTo, QuadTo, CubicTo, Arc (angles in radians, clockwise from the x axis), Close, and whole figures, Rect, RoundedRect and Ellipse.

Stroke and LineWidth (default 1) draw the outline, with a LineCap (CapButt, CapRound, CapSquare) and a LineJoin (JoinMiter, JoinRound, JoinBevel). TrimStart and TrimEnd draw part of it, from 0 to 1: a progress ring, a line that draws itself. Changing them in ui.Animate animates them (see Motion). Dash dashes the stroke: lengths in points, drawn and skipped in turn ([]float32{6, 4}; an odd count repeats), as SwiftUI's StrokeStyle(dash:) does. DashPhase is how far into the pattern it starts. examples/usage draws a forecast with one.

func ring(w, h float32) *ui.Path {
	r := min(w, h)/2 - 4
	return new(ui.Path).Arc(w/2, h/2, r, -math.Pi/2, 3*math.Pi/2, true)
}

func progressRing(done signal.Accessor[float32]) ui.Node {
	box := ui.Style{Absolute: true, Left: ui.Pt(0), Top: ui.Pt(0), Width: ui.Pct(100), Height: ui.Pct(100)}
	return ui.View(ui.ViewProps{Label: ui.Val("Progress"), Style: ui.Style{Width: ui.Pt(72), Height: ui.Pt(72)}},
		ui.Shape(ui.ShapeProps{Path: ring, Stroke: ui.SecondaryBackground, LineWidth: 8, Style: box}),
		ui.Shape(ui.ShapeProps{Path: ring, Stroke: ui.Accent, LineWidth: 8, LineCap: ui.CapRound,
			TrimEnd: done, Style: box}),
	)
}

Canvas

ui.Canvas draws what the app draws: a chart, a gauge, a signature, a game's board, whatever no view is. Its function draws with a *ui.Graphics, in points from its top left:

ui.Canvas(func(g *ui.Graphics) {
	w, h := g.Size()
	g.Fill(g.Path().RoundedRect(0, 0, w, h, 12), ui.SecondaryBackground)
	g.Fill(g.Path().RoundedRect(0, 0, w*level.Get(), h, 12), ui.Accent)
	g.Text(fmt.Sprintf("%.0f%%", level.Get()*100), w/2, h/2+6, ui.TextStyle{Weight: ui.Semibold, Align: ui.TextCenter})
}, ui.Style{Height: ui.Pt(36)})
  • g.Path() is a path to build (as a Shape's), reused from draw to draw.
  • Fill, Stroke (a StrokeStyle: width, caps, joins, dashes) and FillGradient paint a path; Text draws a line of text, its baseline at y, its left edge, center or right edge at x.
  • Save and Restore bracket Translate, Scale, Rotate (degrees, clockwise), Clip and Opacity.
  • Colors can be semantic (ui.Label, ui.Separator, ui.Accent): they follow dark mode.

It draws once it's laid out, and again when its size changes or the signals the function read change, like an effect. Each draw records a display list, sent only when it differs from the last, and the platform replays it: Core Graphics on iOS and macOS, Android's Canvas, GDI+ on Windows, GTK's snapshots (GSK) on Linux, a 2D <canvas> in a browser. Recording allocates nothing once the canvas has drawn as much before. A canvas has no presses of its own: wrap it in a ui.View with OnPress or OnPan, which report points in its coordinates. It isn't read by screen readers: give the view around it a Label.

Charts

asuka/chart draws charts on a Canvas: bars, lines, areas and points over a value axis, with the platform's text for their labels (measured by it, scaled with the user's text size).

chart.View(chart.Props{
	Series: ui.Val([]chart.Series{
		{Name: "Steps", Kind: chart.Bar, Values: steps},
		{Name: "Goal", Kind: chart.Line, Values: goal, Color: ui.Hex(0xff9500)},
	}),
	Labels: ui.Val([]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}),
	Label:  ui.Val("Steps this week"),
	Style:  ui.Style{Height: ui.Pt(200)},
})
  • A series has a value per label; NaN leaves a gap. Bar series stand side by side; areas, lines and points go over them. Smooth draws a monotone curve, which never overshoots its values.
  • The value axis fits the values, rounded out to whole ticks (and down to zero for bars and areas), unless Min and Max fix it; Format writes its labels (2.5k by default).
  • Colors default to the app's accent, then the system's.
  • Screen readers read the chart as one image: its Label, then each series' values, whole.

Shaders

ui.Shader is a box a GPU program draws, under its children: flowing gradients, noise, glows, ripples. The program is a Kage shader (Ebitengine's language: Go's syntax), which package shader compiles once, as a package's variable:

var glow = shader.MustCompile(`package main

var Time float
var Tint vec4

func Fragment(dst vec4, pos vec2) vec4 {
	d := distance(pos/rectSize(), vec2(0.5))
	g := smoothstep(0.5, 0, d) * (0.8 + 0.2*sin(Time*2))
	return vec4(Tint.rgb*g, 1) * Tint.a
}`)

ui.Shader(ui.ShaderProps{
	Program:  glow,
	Uniforms: ui.Val([]ui.Uniform{ui.ColorUniform("Tint", ui.Hex(0x38bdf8))}),
	Animated: true,
	Style:    ui.Style{Height: ui.Pt(200), CornerRadius: 16, Clip: true, Background: ui.Hex(0x0b1030)},
})
  • Fragment returns the color of a pixel, premultiplied, red, green, blue and alpha from 0 to 1. pos is the pixel's center in points from the box's top left, dst the same in pixels; rectSize() is the box's size in points and pixelScale() the pixels a point.
  • The language has bool, int, float, vec2 to vec4, ivec2 to ivec4, mat2 to mat4 and arrays of them, GLSL's built-in functions (sin, mix, smoothstep, dot...), functions of its own, if and for. Exported variables are uniforms.
  • Uniforms sets them by name: a scalar's one number, a vector's components, a matrix's columns in turn. A change draws the box again; a uniform left out is 0.
  • var Time float is the seconds since the shader showed. Animated runs it: the box draws at every frame the display draws, with nothing running in Go.
  • shader.Compile reports a shader's errors with their lines, and what of it Android can't run, on every platform alike: AGSL is GLSL ES 1.00's language, so no unsigned integers, bitwise operations or integer remainders (mod of floats does), derivatives, or loops between values that aren't constants.

It's Metal on iOS and macOS (a CAMetalLayer), AGSL on Android (a RuntimeShader) and WebGL 2 in a browser (the program translated to GLSL ES 3.00). Windows and Linux run the same program on the CPU, across the cores and off the main thread: a frame shows when it's ready, and a large or animated box renders at a fraction of its resolution, scaled up. Give the box a Background that stands in until the first frame.

Containers

ui.View holds other views. ui.Column and ui.Row are views that stack their children top to bottom and leading to trailing. ui.SafeArea is a column padded by the notch, the system bars and the keyboard. How they size and place what's in them is in Layout.

A view with no look, events or accessibility props makes no native view of its own: its children go in the nearest one that does. So nesting rows and columns costs nothing.

open := signal.New(false)
return ui.View(ui.ViewProps{
	OnPress:     func() { open.Set(!open.Peek()) },
	OnLongPress: func() { ui.Haptic(ui.HapticMedium) },
	Label:       ui.Val("Ada, 2 unread"),
	Style:       ui.Style{Direction: ui.RowDir, Gap: 12, Padding: ui.All(16), Background: ui.SecondaryBackground, CornerRadius: 12},
},
	ui.Symbol(ui.IconPerson.Filled()),
	ui.Column(ui.Style{Grow: 1, Gap: 2},
		ui.StaticText("Ada", ui.Style{Font: ui.Headline}),
		ui.StaticText("2 unread", ui.Style{Color: ui.SecondaryLabel}),
	),
)

ViewProps has:

  • OnPress and OnLongPress.
  • Gestures, with the platform's recognizers: OnPan, OnPinch, OnRotate (each given a ui.Gesture) and OnSwipe (a ui.SwipeDirection). See Motion.
  • Interaction states. The view's own style follows them through its variants (Style.Pressed, Hovered, Dragging, DragOver). Other views follow them through Interaction (a ui.Interaction, read with Style.When or in a StyleFunc). Side effects go in OnPressedChange and OnHoverChange. Drag and drop: Drag (a ui.DragSource) and Drop (a ui.DropTarget). See Motion.
  • OnLayout, called with the view's ui.Frame once it's laid out and each time that changes.
  • Accessibility: Label, Hint, Role, Decorative, Actions and OnAdjust. See Accessibility.

Scroll

ui.Scroll scrolls its children along one axis. Give it a size, or Grow, so it has something to scroll within. Style is the scroll view's box; ContentStyle is the box its children are stacked in.

down := signal.New(false)
var scroll ui.Node
scroll = ui.Scroll(ui.ScrollProps{
	Style:    ui.Style{Grow: 1},
	OnScroll: func(_, y float32) { down.Set(y > 300) },
}, ui.SafeArea(ui.Style{Padding: ui.All(20), Gap: 16}, content...))
return ui.View(ui.ViewProps{Style: ui.Style{Grow: 1}}, scroll,
	ui.Show(down, func() ui.Node {
		return ui.View(ui.ViewProps{Style: ui.Style{Absolute: true, Right: ui.Pt(20), Bottom: ui.Pt(20)}},
			ui.Button(ui.ButtonProps{Icon: ui.IconUp, Label: ui.Val("Back to top"), Variant: ui.ButtonFilled,
				OnPress: func() { ui.ScrollTo(scroll, 0, 0, true) }}))
	}))
  • OnScroll is called as it scrolls, with its content offset in points.
  • ui.ScrollTo(scroll, x, y, animated) scrolls to an offset (past the end is the end); ui.ScrollToEnd(scroll, animated) to its bottom, or a horizontal one's trailing edge. Both work on a List too.
  • Horizontal scrolls sideways:
ui.Scroll(ui.ScrollProps{Horizontal: true, ContentStyle: ui.Style{Gap: 8, Padding: ui.Symmetric(0, 16)}}, kids...)

Pages. Paging makes it rest only at whole pages: a carousel, an onboarding's steps, a feed of full-screen videos. Each child of its content is a page, as big as the scroll view along its axis. Page is the page shown: a swipe writes it as it settles, and writing it scrolls there. ui.PageControl shows where it is, a dot per page, and moves it too when one is tapped:

page := signal.New(0)
return ui.Column(ui.Style{Gap: 8},
	ui.Scroll(ui.ScrollProps{Horizontal: true, Paging: true, Page: page, Style: ui.Style{Height: ui.Pt(200)}},
		card(1), card(2), card(3)),
	ui.PageControl(ui.PageControlProps{Count: ui.Val(3), Page: page}))
iOS Android macOS Windows Linux Web
Paging isPagingEnabled each swipe snaps to the next page, or back settles on the nearest page when a swipe ends a page per wheel notch (FlipView's) a page per wheel notch; a touchpad's or touchscreen's swipe settles on a page; the arrow keys, Page Up and Page Down move a page CSS scroll snapping
PageControl UIPageControl dots in the theme's primary color (Material has none) dots in the accent color pips (PipsPager's) dots drawn as libadwaita's, the current one larger and darker dots, the current one in the label color

Pages count from the reading start: right to left, the first is the rightmost. When the scroll view's size changes (a rotation), it stays on its page. Screen readers say "page 2 of 3" on the dots and adjust them.

Pull to refresh. OnRefresh turns it on (UIRefreshControl, SwipeRefreshLayout). Set Refreshing true while you refresh; the spinner hides when it turns false. Without Refreshing, it hides as soon as OnRefresh returns. On macOS a trackpad's or Magic Mouse's scroll pulls it past its top, as on iOS. Windows and Linux refresh the desktop's way: F5 (on Linux, Ctrl+R too) refreshes the scroll view the focus is in, or else the innermost one on screen, a spinner over its top meanwhile. In a browser a touch pulls it down.

refreshing := signal.New(false)
return ui.Scroll(ui.ScrollProps{
	Style:      ui.Style{Grow: 1},
	Refreshing: refreshing,
	OnRefresh: func() {
		refreshing.Set(true)
		go func() {
			time.Sleep(time.Second) // fetch
			ui.Post(func() { refreshing.Set(false) })
		}()
	},
}, ui.StaticText("Pull down"))

A bar on the keyboard. Accessory is a bar shown on top of the keyboard while the field is focused: a count and Done, formatting buttons, quick replies (SwiftUI's keyboard toolbar). It's as wide as the window and as tall as its content, built with the field and disposed with it.

var field ui.Node
field = ui.TextField(ui.TextFieldProps{Value: note, Multiline: true, Accessory: func() ui.Node {
	return ui.Row(ui.Style{Padding: ui.Symmetric(6, 16), Align: ui.AlignCenter, Material: ui.MaterialBar},
		ui.Text(signal.Func(func() string { return fmt.Sprint(len(note.Get()), " characters") }), ui.Style{Grow: 1}),
		ui.Button(ui.ButtonProps{Title: ui.Val("Done"), OnPress: func() { ui.Blur(field) }}))
}})

On iOS it's the field's inputAccessoryView: it moves with the keyboard, through its interactive dismissal too. Android has none of its own: the bar goes in the field's window (a sheet's too) at the top of the keyboard, following it frame by frame. macOS, Windows and Linux, with no on-screen keyboard, don't show it; a browser shows it only on a touch screen.

Styled text. Rich makes a field a text area of styled text, for a mail's body or a formatted message: bold, italic, underline, strikethrough, monospaced code and links (colors and text styles a span has are kept as they are). It's the same ui.Rich a RichText shows. The user's typing and formatting write it; writing it replaces the text. ui.ToggleFormat(field, ui.FormatBold) turns a style on for the selection unless all of it has it, then off (FormatText sets it either way); with no selection, it's what's typed next. ui.SetLink(field, url) links the selection ("" unlinks; at the cursor, the link it's in). OnFormats reports the styles where the cursor is, or that all the selection has, for a toolbar to show.

body := signal.NewFunc(ui.Rich{ui.Plain("Hi,\n\nThe "), ui.Strong("1.0"), ui.Plain(" build is ready.")}, nil)
formats := signal.New(ui.TextFormats{})
var field ui.Node
field = ui.TextField(ui.TextFieldProps{Rich: body, Plain: true, OnFormats: formats.Set, Style: ui.Style{Grow: 1}})
bold := ui.Button(ui.ButtonProps{Icon: "bold", Label: ui.Val("Bold"),
	Style:   ui.StyleFunc(func() ui.Style { if formats.Get().Has(ui.FormatBold) { return ui.Style{Background: ui.SecondaryBackground} }; return ui.Style{} }),
	OnPress: func() { ui.ToggleFormat(field, ui.FormatBold) }})
iOS Android macOS Windows Linux Web
The view UITextView, its attributed text EditText, its spans NSTextView, rich a rich edit (RICHEDIT50W), through RTF GtkTextView, its buffer's tags a contenteditable element
The user's formatting the edit menu's B/I/U a hardware keyboard's Ctrl+B, Ctrl+I, Ctrl+U the Format menu, ⌘B ⌘I ⌘U Ctrl+B, Ctrl+I, Ctrl+U Ctrl+B, Ctrl+I, Ctrl+U the browser's ⌘B ⌘I ⌘U (Ctrl elsewhere)
Undo of formatting yes yes: the field's own history (Android's keeps text only), Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y yes yes GTK's history (Ctrl+Z, Ctrl+Shift+Z), which keeps text only yes: the page's editing commands

examples/mail has a composer with a toolbar.

Padding. A text's or a field's Padding (and BorderWidth) is inside its frame, around its content, as in CSS: its background fills it, its text is drawn inside it, and a field's editing happens inside it.

Lists

ui.List shows a row per item in the platform's recycling list (UICollectionView, RecyclerView). The desktop and the web have no list that holds rows of any view, so there the host recycles rows itself, in the platform's scroll view. Rows are made only for the cells on screen; a cell scrolled away is reused for another item, and only the props that differ are updated. examples/list10k has ten thousand rows.

messages := signal.NewFunc(fetch(0, 30), nil)
return ui.List(ui.ListProps{}, messages, func(m message) int { return m.ID }, row)
func row(m signal.Accessor[message], _ signal.Accessor[int]) ui.Node {
	return ui.Column(ui.Style{Gap: 2, Padding: ui.Symmetric(12, 20)},
		ui.Text(signal.Func(func() string { return m.Get().From }), ui.Style{Font: ui.Headline}),
		ui.Text(signal.Func(func() string { return m.Get().Subject }), ui.Style{Color: ui.SecondaryLabel}),
	)
}

It takes the items, a key for each, and a function that builds a row from an item and its index, both accessors. Keys say which item is which from one list to the next: a changed item updates its row, a moved one moves it, and the platform animates the difference. Read the item in a signal.Func, as above, so a reused row follows its new item.

A list fills the space it's given: with no height and no Grow, it grows. It draws the platform's separators between rows.

ListProps has:

  • SwipeActions, the actions of row i, revealed by swiping it. On iOS they are its trailing actions, a destructive one red, the first run by a full swipe. On Android a full swipe runs the first, over its color and icon, as Material's swipe to dismiss. On macOS, Windows, Linux and the web they're the row's context menu (a secondary click; on Linux and the web a long press too), leading then trailing. Give the row the same as accessibility Actions, for screen readers.
  • OnEndReached, called when the last rows are about to show, once for each length the list has: load more there.
  • OnRefresh and Refreshing, pull to refresh as on a Scroll (F5 on Windows and Linux).
  • OnScroll, as on a Scroll.
  • Appearance, its look. ui.ListPlain is rows edge to edge, apart by separators; ui.ListInsetGrouped is the platform's grouped list (libadwaita's boxed lists on Linux); ui.ListBare has no separators and nothing behind its rows, so what's behind the list shows through, for rows that draw their own cards. A bare list's rows leave their own space, and can still be swiped.
messages := signal.NewFunc(fetch(0, 30), nil)
remove := func(i int) {
	messages.Update(func(ms []message) []message { return slices.Delete(slices.Clone(ms), i, i+1) })
}
list := ui.List(ui.ListProps{
	SwipeActions: func(i int) []ui.Action {
		return []ui.Action{
			{Title: ui.Val("Delete"), Icon: ui.IconTrash, Role: ui.ActionDestructive, OnPress: func() { remove(i) }},
			{Title: ui.Val("Archive"), Icon: "archivebox", OnPress: func() { remove(i) }},
		}
	},
	OnEndReached: func() {
		ms := messages.Peek()
		messages.Set(append(slices.Clone(ms), fetch(len(ms), 30)...))
	},
}, messages, func(m message) int { return m.ID }, row)

LeadingSwipeActions are revealed by swiping the other way, from the row's leading edge (Mail's Unread, Pin): the first is at that edge, and a full swipe's. iOS tints them; Android shows them as Material's filled buttons.

ui.ScrollToItem(list, index, animated) scrolls a list so an item shows; ScrollTo and ScrollToEnd work too.

Chats: a list anchored at its bottom

Anchor: ui.AnchorBottom is a chat's list: it opens at its last row, rows that don't fill it sit at its bottom, and while it's at its end it stays there: as rows are added at the end, as they change height, and as the list gets shorter (the keyboard). The user scrolling up leaves the end; scrolling back to it, or ui.ScrollToEnd, sticks again. OnStartReached is called when the first rows are about to show, once for each first item: load older messages there.

Whatever the anchor, rows added or removed above the ones on screen don't move those (React Native's maintainVisibleContentPosition): older messages loaded at the top, or an item removed above, leave the user reading what they were. Only at the edge the list is anchored to does it show the new rows: at the top of a list read from the top (a new mail), at the end of a chat.

msgs := signal.NewFunc(latest(40), nil)
var list ui.Node
list = ui.List(ui.ListProps{
	Anchor:         ui.AnchorBottom,
	OnStartReached: func() { go loadOlder() }, // then ui.Post prepends them
}, msgs, func(m message) int { return m.ID }, bubble)
send := func(text string) {
	msgs.Set(append(slices.Clone(msgs.Peek()), message{Text: text}))
	ui.ScrollToEnd(list, true) // one's own message shows, wherever the list was
}

examples/chat is a whole conversation.

Sections

Section puts rows in sections: rows next to each other with the same key are one, headed by Header's view (a day in a chat, a letter in contacts, Today and Yesterday in a mailbox). Header gets the key as an accessor: a header is a row of its own, made as it comes into view and reused for another section after.

ui.List(ui.ListProps{
	Section: func(i int) string { return day(msgs.Peek()[i].At) },
	Header: func(day signal.Accessor[string]) ui.Node {
		return ui.Text(day, ui.Style{Font: ui.Footnote.Weighted(ui.Semibold), Padding: ui.Symmetric(6, 20)})
	},
}, msgs, key, row)

A plain or bare list's header stays at the top while its section's rows scroll under it, and the next header pushes it up (UITableView's plain style; on Android, which has none, a cell over the list, bound to the section at the top; on the desktop and the web, the header row itself). A plain list's header is on the list's background; a bare list's draws its own (a chat's pill over the messages). An inset grouped list's sections are groups of their own, each under its header, which scrolls with it.

Selecting rows

Selecting puts a list in selection mode (Mail's Edit): each row shows the platform's selection control, a tap selects or deselects it instead of pressing it, and rows don't swipe. Selected says which rows are (it's followed in an effect, so a selection kept in signals shows as it changes), and OnSelect hears the user's choices; what the app doesn't take goes back. The rows are laid out narrower by the control's room.

selecting := signal.New(false)
chosen := signal.NewFunc(map[int]bool{}, nil) // by message ID
ui.List(ui.ListProps{
	Selecting: selecting,
	Selected:  func(i int) bool { return chosen.Get()[msgs.Peek()[i].ID] },
	OnSelect: func(i int, v bool) {
		m := maps.Clone(chosen.Peek())
		if v {
			m[msgs.Peek()[i].ID] = true
		} else {
			delete(m, msgs.Peek()[i].ID)
		}
		chosen.Set(m)
	},
	OnSelectBegin: func(i int) { selecting.Set(true) },
}, msgs, key, row)
iOS Android macOS, Windows, Linux Web
The control UIKit's edit mode circles a Material checkbox at the row's end the row highlighted (the accent's selection color; a WinUI list's pill on Windows) a circle at the row's leading edge that fills with a checkmark, the row tinted
OnSelectBegin a two-finger pan over rows, which goes on to select the rows it crosses a long press (Gmail's) — —
Screen readers each row selectable each row a checkbox, its text the label — each row selected or not (aria-selected)

Grids

Columns makes a list a grid (a LazyVGrid, a GridLayoutManager): items fill rows of that many, each as wide as the others, Gap apart across and down. A row is as tall as its tallest item. A grid has no separators and no swipe actions.

ui.List(ui.ListProps{Columns: 3, Gap: 2}, ui.Val(ids), func(id int) int { return id },
	func(id signal.Accessor[int], _ signal.Accessor[int]) ui.Node {
		return ui.Image(ui.ImageProps{
			Source: signal.Func(func() string { return fmt.Sprintf("https://picsum.photos/id/%d/300/300", id.Get()) }),
			Mode:   ui.ImageFill,
			Style:  ui.Style{AspectRatio: 1},
		})
	})

List or For in a Scroll

ui.For makes a row per item too, keyed the same way, but it makes all of them, as plain views among their siblings. Use it in a Scroll for a short list that's part of a page: a few tags, a form's rows, a section under a header.

ui.Scroll(ui.ScrollProps{Style: ui.Style{Grow: 1}, ContentStyle: ui.Style{Padding: ui.All(20), Gap: 8}},
	ui.StaticText("Tags", ui.Style{Font: ui.Headline}),
	ui.For(tags, func(t string) string { return t }, func(t signal.Accessor[string], _ signal.Accessor[int]) ui.Node {
		return ui.Text(t)
	}),
)

Use List when the items are many or unbounded (a feed, an inbox, search results, photos), or you want swipe actions, separators, loading more at the end, or a grid. A List scrolls by itself: don't put it in a Scroll.

Your own views

When a platform has a widget Asuka doesn't, write it yourself and show it with ui.Native: in Swift on iOS, Kotlin on Android, Go on macOS, Windows and Linux (AppKit, Win32 and GTK through package native), and JavaScript on the web. See Native code.