Advanced
July 11, 2026 · View on GitHub
Keyboard shortcuts, async goroutines, toast notifications, modals, tabs, accordion, context menus, and everything else that doesn't fit neatly into the earlier pages.
Toast Notifications
A timed message that appears, stays for a few seconds, and disappears on its own. No dialog, no blocking the user.
type UI struct {
toast proton.ToastState
}
// trigger from anywhere — goroutine-safe
u.toast.Show("File saved!", 2*time.Second)
// draw it LAST in your draw function so it renders on top of everything
proton.Toast(ctx, &u.toast)
If there's no active toast, Toast draws nothing. No need to check first.
func (t *ToastState) Show(msg string, duration time.Duration)
proton.Toast(ctx proton.Context, state *proton.ToastState)
Overlay / Modal
A dimmed backdrop with centered content on top of everything.
type UI struct {
modal proton.OverlayState
openBtn proton.Clickable
closeBtn proton.Clickable
}
// open it
proton.Pad(ctx, 4, func(ctx proton.Context) {
if proton.Button(ctx, &u.openBtn, "Open dialog") {
u.modal.Show()
}
})
// draw it — also at the end of your draw function
proton.Overlay(ctx, &u.modal, func(ctx proton.Context) {
proton.MinSize(ctx, 300, 0, func(ctx proton.Context) {
proton.Card(ctx, proton.RGB(0x2e3440), 12, 24, func(ctx proton.Context) {
proton.H5(ctx, "Are you sure?")
proton.Gap(ctx, 8)
proton.Label(ctx, "This cannot be undone.")
proton.Gap(ctx, 20)
proton.RowEnd(ctx,
func(ctx proton.Context) {
proton.Pad(ctx, 4, func(ctx proton.Context) {
if proton.OutlineButton(ctx, &u.closeBtn, "Cancel") {
u.modal.Hide()
}
})
},
func(ctx proton.Context) { proton.Gap(ctx, 8) },
func(ctx proton.Context) {
proton.Pad(ctx, 4, func(ctx proton.Context) {
if proton.Button(ctx, &u.openBtn, "Confirm") {
u.modal.Hide()
doThing()
}
})
},
)
})
})
})
func (o *OverlayState) Show()
func (o *OverlayState) Hide()
func (o *OverlayState) Toggle()
proton.Overlay(ctx proton.Context, state *proton.OverlayState, content func(proton.Context))
Overlay draws nothing when state.Visible is false, so you can call it
every frame without any wrapping condition.
Keyboard Shortcuts
Register a function to fire when a key combination is pressed.
proton.OnKey(ctx, proton.ModCtrl, "S", func() { save() })
proton.OnKey(ctx, proton.ModCtrl, "Z", func() { undo() })
proton.OnKey(ctx, proton.ModCtrl|proton.ModShift, "N", func() { newFile() })
proton.OnKey(ctx, proton.ModNone, proton.KeyEscape, func() { closeDialog() })
Call OnKey inside your draw function. It registers the shortcut for that
frame. Since the draw function runs every frame, shortcuts stay active as
long as the window is open.
proton.OnKey(ctx proton.Context, modifiers proton.Modifier, keyName string, fn func())
Modifier constants:
proton.ModNone // no modifier — just the key
proton.ModCtrl // Ctrl (Cmd on macOS)
proton.ModShift
proton.ModAlt
// combine with |
proton.ModCtrl | proton.ModShift
Key name constants (for non-letter keys):
proton.KeyEscape
proton.KeyReturn
proton.KeyBackspace
proton.KeyDelete
proton.KeyTab
proton.KeySpace
proton.KeyUp
proton.KeyDown
proton.KeyLeft
proton.KeyRight
Letter keys are just strings: "S", "Z", "N", "A".
Tabs
A horizontal tab bar with one content area that switches based on the selected tab.
type UI struct {
tabs proton.TabState
tabBtns [3]proton.Clickable
}
proton.Tabs(ctx,
[]string{"Files", "Settings", "About"},
u.tabBtns[:],
&u.tabs,
func(ctx proton.Context, i int) {
switch i {
case 0: drawFiles(ctx)
case 1: drawSettings(ctx)
case 2: drawAbout(ctx)
}
},
)
u.tabs.Selected holds the active tab index. You can set it programmatically
to switch tabs from code.
proton.Tabs(ctx proton.Context, labels []string, btns []proton.Clickable, state *proton.TabState, content func(proton.Context, int))
The btns slice needs one Clickable per tab. Passing u.tabBtns[:] is
the idiomatic way when you declare a fixed-size array in your struct.
Accordion
A collapsible section with a clickable header.
type UI struct {
sec1 proton.AccordionState
sec1btn proton.Clickable
}
proton.Accordion(ctx, &u.sec1, &u.sec1btn, "Advanced Options", func(ctx proton.Context) {
proton.Label(ctx, "These options are hidden until the user expands this.")
proton.Gap(ctx, 8)
proton.Toggle(ctx, &u.expertMode, "Expert mode")
})
proton.Accordion(ctx proton.Context, state *proton.AccordionState, btn *proton.Clickable, title string, content func(proton.Context))
state.Open tracks whether it's expanded. You can set it directly to start
an accordion open: u.sec1.Open = true.
Context Menu
A right-click menu that appears at the cursor position.
type UI struct {
menu proton.ContextMenuState
menuTag proton.FrameTag
}
items := []proton.ContextMenuItem{
{Label: "Copy"},
{Label: "Paste"},
{Label: "Delete"},
{Label: "Disabled option", Disabled: true},
}
chosen := proton.ContextMenu(ctx, &u.menu, &u.menuTag, items, func(ctx proton.Context) {
proton.Label(ctx, "Right-click anywhere in this area")
})
if chosen >= 0 {
fmt.Println("User picked:", items[chosen].Label)
}
proton.ContextMenu(ctx proton.Context, state *proton.ContextMenuState, tag *proton.FrameTag, items []proton.ContextMenuItem, content func(proton.Context)) int
Returns -1 when nothing was selected, and the item index on the frame something gets clicked. The menu closes automatically after a selection.
Async Updates and Goroutines
Your draw function runs on the main thread. When a goroutine finishes work
and changes state, call ctx.Invalidate() to ask for a redraw.
type UI struct {
loading bool
result string
fetchBtn proton.Clickable
}
// in your draw function
proton.Pad(ctx, 4, func(ctx proton.Context) {
if proton.Button(ctx, &u.fetchBtn, "Fetch") && !u.loading {
u.loading = true
go func() {
data := fetchFromAPI() // takes a while
u.result = data
u.loading = false
ctx.Invalidate() // wake up the render loop
}()
}
})
if u.loading {
proton.Row(ctx,
func(ctx proton.Context) { proton.Spinner(ctx, &u.spin, 18) },
func(ctx proton.Context) { proton.Gap(ctx, 8) },
func(ctx proton.Context) { proton.Muted(ctx, "Loading...") },
)
} else if u.result != "" {
proton.Label(ctx, u.result)
}
Without ctx.Invalidate(), the window won't redraw until the user moves
the mouse or interacts with it. Always call it after changing state from
a goroutine.
Spinner
An animated loading indicator. Calling Spinner automatically keeps the
window redrawing — no Invalidate() loop needed.
type UI struct {
spin proton.SpinnerState
}
proton.Spinner(ctx, &u.spin, 32) // 32dp diameter
proton.Spinner(ctx proton.Context, state *proton.SpinnerState, sizeDp float32)
SpinnerState tracks the animation start time. Declare one per spinner
in your state struct.
SelectBox (Dropdown)
type UI struct {
langSel proton.SelectBoxState
}
langs := []string{"Go", "Rust", "Zig", "C", "Python"}
i := proton.SelectBox(ctx, &u.langSel, langs)
proton.Caption(ctx, "Selected: "+langs[i])
The dropdown opens below the button and closes on selection or outside click.
proton.SelectBox(ctx proton.Context, state *proton.SelectBoxState, options []string) int
If — Conditional Rendering
Renders content only when a condition is true. Saves a if block when you
just want to show or hide a single widget.
proton.If(ctx, user.IsAdmin, func(ctx proton.Context) {
proton.Pad(ctx, 4, func(ctx proton.Context) {
if proton.Button(ctx, &u.deleteBtn, "Delete everything") {
deleteEverything()
}
})
})
proton.If(ctx proton.Context, cond bool, content func(proton.Context))
FocusArea — Scoped Key Handling
When you need keyboard events only active inside a specific region of the UI,
not globally. Usually OnKey is enough — reach for this when you have two
panels that should have independent keyboard shortcuts.
type UI struct {
editorTag proton.FrameTag
}
proton.FocusArea(ctx, &u.editorTag, "A", func(ctx proton.Context) {
proton.TextArea(ctx, &u.text, "Type here...")
})
proton.FocusArea(ctx proton.Context, tag *proton.FrameTag, keyName string, content func(proton.Context))
Window Options
// fullscreen
a.WindowEx("App", 800, 600, []proton.WindowOption{
proton.Fullscreen(),
}, draw)
// maximized
a.WindowEx("App", 800, 600, []proton.WindowOption{
proton.Maximized(),
}, draw)
proton.Fullscreen() proton.WindowOption
proton.Maximized() proton.WindowOption
Keeping Animations Running
Proton only redraws when there's user input or you call ctx.Invalidate().
For animations — progress bars that fill over time, countdowns, anything
time-based — call Invalidate at the end of each frame to keep the redraws
going:
func draw(ctx proton.Context, u *UI) {
if u.animating {
u.progress += 0.01
if u.progress >= 1.0 {
u.progress = 0
u.animating = false
}
proton.ProgressBar(ctx, u.progress)
ctx.Invalidate() // draw again next frame
}
}
When u.animating goes false, Invalidate stops being called and Proton
goes back to redrawing only on user input. The Spinner widget does this
automatically — you don't need to manage it yourself.