Recipes for common UI patterns built from Plushie's built-in widgets. Each pattern shows complete code with the view and update logic. These are not special framework features. They are compositions of existing widgets and state helpers.
| Section | Patterns |
|---|---|
| Navigation | Tabs, sidebar, breadcrumbs, route dispatch |
| Overlays | Modal dialog, toast notifications, popover, loading indicator |
| Layout | Toolbar, cards, split panel, badges |
| Forms | Validated fields, search and filter |
| State helpers | Selection, undo, data query with sort |
| Interaction | Debounced search, context menu, keyboard shortcuts, focus management, multi-window |
Navigation
Tabs
Buttons in a row with conditional content. Track the active tab in the model.
def view(model) do
tabs = [:overview, :details, :settings]
window "main", title: "Tabs" do
column width: :fill do
row spacing: 0 do
for tab <- tabs do
button(
"tab:#{tab}",
tab |> Atom.to_string() |> String.capitalize(),
style: if(model.active_tab == tab, do: :primary, else: :text)
)
end
end
rule()
case model.active_tab do
:overview -> overview_content(model)
:details -> details_content(model)
:settings -> settings_content(model)
end
end
end
end
def update(model, %WidgetEvent{type: :click, id: "tab:" <> tab_name}) do
%{model | active_tab: String.to_existing_atom(tab_name)}
endSidebar navigation
A fixed-width column alongside the main content. The sidebar has a fixed pixel width; the content area fills the rest.
row width: :fill, height: :fill do
column width: 200, height: :fill, padding: 8, spacing: 4 do
for item <- nav_items do
button(item.id, item.label,
width: :fill,
style: if(item.id == model.active, do: :primary, else: :text)
)
end
end
container "content", width: :fill, height: :fill, padding: 16 do
render_active_page(model)
end
endBreadcrumbs
Interleaved buttons and separators. The last segment is plain text (current location); earlier segments are clickable for navigation.
row spacing: 4 do
for {segment, i} <- Enum.with_index(model.breadcrumbs) do
if i > 0 do
text("sep-#{i}", "/", size: 12, color: "#999")
end
if i == length(model.breadcrumbs) - 1 do
text("crumb-#{i}", segment, size: 12)
else
button("crumb-#{i}", segment, style: :text)
end
end
endRoute-based view dispatch
Use Plushie.Route for a navigation stack with back/forward and
per-route parameters:
def view(model) do
window "main", title: "App" do
case Route.current(model.route) do
:list -> list_view(model)
:detail -> detail_view(model, Route.params(model.route))
:settings -> settings_view(model)
end
end
end
def update(model, %WidgetEvent{type: :click, id: "show-detail", scope: [item_id | _]}) do
%{model | route: Route.push(model.route, :detail, %{id: item_id})}
end
def update(model, %WidgetEvent{type: :click, id: "back"}) do
%{model | route: Route.pop(model.route)}
endOverlays
Overlays (modals, toasts, popovers) must be placed at the window
level to stay visible regardless of scrolling. A stack as the
direct child of the window layers overlay content on top of everything
else. If you put an overlay inside a scrollable or a fixed-height
container, it scrolls or clips with that container.
def view(model) do
window "main", title: "App" do
stack width: :fill, height: :fill do
# Layer 0: all app content (may scroll internally)
main_content(model)
# Layer 1: modal (covers full window when active)
if model.show_modal, do: modal_overlay(model)
# Layer 2: toasts (topmost, visible even over modals)
toast_overlay(model)
end
end
endModal dialog
The modal overlay covers the full window with a semi-transparent
backdrop and centres the dialog. The if without else returns nil,
which stack filters out.
defp modal_overlay(model) do
container "overlay",
width: :fill, height: :fill,
background: "#00000088",
center: true do
container "dialog", padding: 24, background: "#ffffff",
border: Border.new() |> Border.rounded(8) do
column spacing: 12 do
text("modal-title", "Confirm", size: 18)
text("modal-body", "Are you sure?")
row spacing: 8 do
button("modal-cancel", "Cancel")
button("modal-confirm", "Confirm", style: :primary)
end
end
end
end
end
def update(model, %WidgetEvent{type: :click, id: "delete"}) do
%{model | show_modal: true, pending_action: :delete}
end
def update(model, %WidgetEvent{type: :click, id: "modal-confirm"}) do
model = perform_action(model, model.pending_action)
%{model | show_modal: false, pending_action: nil}
end
def update(model, %WidgetEvent{type: :click, id: "modal-cancel"}) do
%{model | show_modal: false, pending_action: nil}
endToast notifications
Toasts are transient messages that auto-dismiss after a timeout. Place
them at the window level in a stack so they stay visible regardless
of scroll position. Use Command.send_after for auto-dismiss and
Command.announce for screen reader announcements.
# Model: toasts is a list of %{id: string, text: string, level: atom}
defp toast_overlay(model) do
if model.toasts != [] do
container width: :fill, height: :fill, align_x: :right, align_y: :bottom do
column spacing: 8, padding: 16 do
for toast <- model.toasts do
container toast.id, padding: {8, 16},
background: toast_color(toast.level),
border: Border.new() |> Border.rounded(6),
opacity: transition(200, to: 1.0, from: 0.0) do
row spacing: 8 do
text(toast.id <> "-msg", toast.text, color: "#fff")
button(toast.id <> "-dismiss", "x", style: :text)
end
end
end
end
end
end
end
defp toast_color(:error), do: "#ef4444"
defp toast_color(:success), do: "#22c55e"
defp toast_color(_), do: "#333333"
# Show a toast with auto-dismiss:
defp show_toast(model, text, level \\ :info) do
id = "toast-#{:erlang.unique_integer([:positive])}"
toast = %{id: id, text: text, level: level}
model = %{model | toasts: model.toasts ++ [toast]}
# Auto-dismiss after 5 seconds; announce for screen readers
{model, Command.batch([
Command.send_after(5000, {:dismiss_toast, id}),
Command.announce(text)
])}
end
def update(model, {:dismiss_toast, id}) do
%{model | toasts: Enum.reject(model.toasts, &(&1.id == id))}
end
def update(model, %WidgetEvent{type: :click, id: id, scope: [toast_id | _]})
when id in ["-dismiss"] do
%{model | toasts: Enum.reject(model.toasts, &(&1.id == toast_id))}
endPopover
The overlay widget positions floating content relative to an anchor.
First child is the anchor, second is the overlay. Exactly two children
required.
overlay "menu", position: :below, gap: 4, flip: true do
button("trigger", "Options")
container "dropdown", padding: 8, background: "#fff",
border: Border.new() |> Border.width(1) |> Border.color("#ddd") |> Border.rounded(4) do
column spacing: 2 do
button("opt-edit", "Edit", style: :text, width: :fill)
button("opt-delete", "Delete", style: :text, width: :fill)
end
end
endflip: true auto-repositions when the overlay would go off-screen.
Loading indicator
Conditionally show a loading overlay while async work is in flight. Like modals, place this at the window level so it covers scrollable content:
# Inside the window-level stack:
if model.loading do
container width: :fill, height: :fill, background: "#ffffff88", center: true do
text("loading", "Loading...", size: 16)
end
end
def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
{%{model | loading: true}, Command.async(fn -> fetch_data() end, :fetch)}
end
def update(model, %AsyncEvent{tag: :fetch, result: {:ok, data}}) do
%{model | loading: false, data: data}
endLayout patterns
Toolbar
A row with button groups, separators, and trailing alignment.
space(width: :fill) pushes everything after it to the right.
row spacing: 4, padding: {4, 8} do
button("bold", "B")
button("italic", "I")
rule(direction: :vertical, height: 20)
button("align-left", "Left")
button("align-center", "Center")
space(width: :fill)
button("settings", "Settings")
endCards
A reusable card helper with border, shadow, and padding:
defp card(id, do: block) do
container id,
padding: 16,
background: "#ffffff",
border: Border.new() |> Border.color("#e5e7eb") |> Border.width(1) |> Border.rounded(8),
shadow: Shadow.new() |> Shadow.color("#0000001a") |> Shadow.offset(0, 2) |> Shadow.blur_radius(4) do
block
end
end
# Usage:
card "user-info" do
column spacing: 8 do
text("name", model.user.name, size: 16)
text("email", model.user.email, color: "#666")
end
endSplit panel
A resizable divider between two panes. Use pointer_area for drag
tracking and a subscription for mouse move during drag:
row width: :fill, height: :fill do
container "left", width: model.split_width, height: :fill do
left_content(model)
end
pointer_area "divider",
cursor: :resizing_horizontally,
on_press: "drag-start",
on_release: "drag-end" do
container width: 4, height: :fill, background: "#ddd" do
end
end
container "right", width: :fill, height: :fill do
right_content(model)
end
end
def subscribe(model) do
if model.dragging do
[Plushie.Subscription.on_pointer_move(:drag, max_rate: 60)]
else
[]
end
end
def update(model, %WidgetEvent{type: :click, id: "drag-start"}), do: %{model | dragging: true}
def update(model, %WidgetEvent{type: :click, id: "drag-end"}), do: %{model | dragging: false}
def update(model, %WidgetEvent{type: :move, data: %{x: x}}), do: %{model | split_width: max(100, x)}Badges and chips
Pills with large border radius. rounded(999) clamps to the maximum,
creating a pill shape.
container "badge",
padding: {2, 8},
background: "#3b82f6",
border: Border.new() |> Border.rounded(999) do
text("count", "#{model.unread}", color: "#fff", size: 12)
endForm patterns
Validated form field
Show inline validation errors below the input:
column spacing: 4 do
text_input("email", model.email, placeholder: "Email",
a11y: %{required: true, invalid: model.email_error != nil})
if model.email_error do
text("email-error", model.email_error, color: "#ef4444", size: 12)
end
end
def update(model, %WidgetEvent{type: :input, id: "email", value: email}) do
error = if String.contains?(email, "@"), do: nil, else: "Must be a valid email"
%{model | email: email, email_error: error}
endSearch and filter
A text input that filters a list in real time using Plushie.Data:
column spacing: 8 do
text_input("search", model.query, placeholder: "Search...")
keyed_column spacing: 4 do
for item <- filtered_items(model) do
text(item.id, item.name)
end
end
end
defp filtered_items(model) do
if model.query == "" do
model.items
else
Data.query(model.items, search: {[:name], model.query}).entries
end
endState helper patterns
Selection with highlighting
Use Plushie.Selection to manage single or multi-select state. The
selection state is a standalone data structure. Toggle items and
check membership:
# In init:
selection: Selection.new(mode: :multi)
# In view:
keyed_column spacing: 4 do
for item <- model.items do
container item.id,
style: if(Selection.selected?(model.selection, item.id),
do: :primary, else: :transparent) do
row spacing: 8 do
checkbox("select", Selection.selected?(model.selection, item.id))
text(item.id <> "-name", item.name)
end
end
end
end
# In update:
def update(model, %WidgetEvent{type: :toggle, id: "select", scope: [item_id | _]}) do
%{model | selection: Selection.toggle(model.selection, item_id)}
endUndo with coalesced keystrokes
Use Plushie.Undo to track reversible changes. Coalescing groups
rapid sequential changes (like typing) into a single undo step:
# In init:
undo: Undo.new("")
# In update: track editor changes with coalescing
def update(model, %WidgetEvent{type: :input, id: "editor", value: text}) do
undo = Undo.apply(model.undo, %{
apply: fn _ -> text end,
undo: fn _ -> model.source end,
coalesce: :typing,
coalesce_window_ms: 500
})
%{model | source: text, undo: undo}
end
# Ctrl+Z / Ctrl+Shift+Z:
def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: false}}) do
if Undo.can_undo?(model.undo) do
undo = Undo.undo(model.undo)
%{model | undo: undo, source: Undo.current(undo)}
else
model
end
end
def update(model, %KeyEvent{key: "z", modifiers: %{command: true, shift: true}}) do
if Undo.can_redo?(model.undo) do
undo = Undo.redo(model.undo)
%{model | undo: undo, source: Undo.current(undo)}
else
model
end
endData query with sort controls
Use Plushie.Data to filter, sort, and paginate in-memory collections:
defp query_items(model) do
Data.query(model.items,
search: if(model.query != "", do: {[:name, :email], model.query}),
sort: {model.sort_dir, model.sort_field},
page: model.page,
page_size: 25
)
end
# In view: sortable column headers
row spacing: 0 do
for col <- [:name, :email, :role] do
button("sort:#{col}", "#{col} #{sort_indicator(model, col)}",
style: :text, width: {:fill_portion, 1})
end
end
# In update: toggle sort direction
def update(model, %WidgetEvent{type: :click, id: "sort:" <> field}) do
field = String.to_existing_atom(field)
dir = if model.sort_field == field and model.sort_dir == :asc, do: :desc, else: :asc
%{model | sort_field: field, sort_dir: dir}
endInteraction patterns
Debounced search
Don't search on every keystroke. Wait for a pause. Use
Command.send_after which cancels-and-restarts on each keystroke.
The search only fires when the user stops typing:
def update(model, %WidgetEvent{type: :input, id: "search", value: query}) do
# Update the display immediately
model = %{model | query: query}
# Debounce: cancel previous timer, start new one
{model, Command.send_after(300, {:run_search, query})}
end
def update(model, {:run_search, query}) do
# Only runs if no keystroke arrived in the last 300ms
%{model | results: search(model.items, query)}
endsend_after with the same event term cancels the previous timer
automatically. No manual cleanup needed.
Context menu
Right-click menu using pointer_area and the window-level stack:
# In the content area, wrap the target in a pointer_area:
pointer_area "item-#{item.id}",
on_right_press: true do
text(item.id, item.name)
end
def update(model, %WidgetEvent{type: :press, data: %{button: :right}, scope: [item_id | _]}) do
%{model | context_menu: %{item_id: item_id, x: model.cursor_x, y: model.cursor_y}}
end
# In the window-level stack, render the menu at the cursor position:
if model.context_menu do
pin x: model.context_menu.x, y: model.context_menu.y do
container "ctx-menu", padding: 4, background: "#fff",
border: Border.new() |> Border.width(1) |> Border.color("#ddd") |> Border.rounded(4),
shadow: Shadow.new() |> Shadow.color("#0000001a") |> Shadow.blur_radius(8) do
column spacing: 2 do
button("ctx-edit", "Edit", style: :text, width: :fill)
button("ctx-delete", "Delete", style: :text, width: :fill)
end
end
end
endDismiss on any click outside the menu or on Escape.
Keyboard shortcuts
Organise shortcuts with a dedicated function to keep update/2 clean:
def subscribe(_model) do
[Plushie.Subscription.on_key_press(:keys)]
end
def update(model, %KeyEvent{type: :press} = key) do
case shortcut(key) do
:save -> save(model)
:undo -> undo(model)
:redo -> redo(model)
:new -> {model, Command.focus("new-name")}
:find -> {model, Command.focus("search")}
:escape -> %{model | context_menu: nil, show_modal: false}
nil -> model
end
end
defp shortcut(%KeyEvent{key: "s", modifiers: %{command: true}}), do: :save
defp shortcut(%KeyEvent{key: "z", modifiers: %{command: true, shift: false}}), do: :undo
defp shortcut(%KeyEvent{key: "z", modifiers: %{command: true, shift: true}}), do: :redo
defp shortcut(%KeyEvent{key: "n", modifiers: %{command: true}}), do: :new
defp shortcut(%KeyEvent{key: "f", modifiers: %{command: true}}), do: :find
defp shortcut(%KeyEvent{key: :escape}), do: :escape
defp shortcut(_), do: nilThe command modifier is platform-aware: Ctrl on Linux/Windows, Cmd on
macOS.
Focus management
Return focus to the right place after actions:
# After deleting an item, focus the next item in the list:
def update(model, %WidgetEvent{type: :click, id: "delete", scope: [item_id | _]}) do
index = Enum.find_index(model.items, &(&1.id == item_id))
items = List.delete_at(model.items, index)
next_id = Enum.at(items, min(index, length(items) - 1))
focus_cmd = if next_id, do: Command.focus("#{next_id.id}/select"), else: Command.none()
{%{model | items: items}, focus_cmd}
end
# After closing a modal, return focus to the element that opened it:
def update(model, %WidgetEvent{type: :click, id: "modal-confirm"}) do
model = perform_action(model, model.pending_action)
{%{model | show_modal: false}, Command.focus(model.modal_trigger_id)}
endGood focus management prevents keyboard and screen reader users from losing their place. Always think about where focus should go after removing content, closing dialogs, or completing flows.
Multi-window detail view
Open an item in its own window. The view/1 conditionally adds a
second window when an item is detached:
def view(model) do
windows = [
window "main", title: "Items" do
keyed_column spacing: 4 do
for item <- model.items do
container item.id do
row spacing: 8 do
text(item.id <> "-name", item.name)
button("detach", "Open in window")
end
end
end
end
end
]
for id <- model.detached_items, item = find_item(model, id), reduce: windows do
acc ->
acc ++ [
window "detail:#{id}",
title: item.name,
exit_on_close_request: false do
detail_view(item)
end
]
end
end
def update(model, %WidgetEvent{type: :click, id: "detach", scope: [item_id | _]}) do
%{model | detached_items: [item_id | model.detached_items]}
end
def update(model, %WindowEvent{type: :close_requested, window_id: "detail:" <> item_id}) do
%{model | detached_items: List.delete(model.detached_items, item_id)}
endEach detached window has exit_on_close_request: false so closing it
only removes it from the view rather than exiting the app.
See also
- Built-in Widgets reference - widget catalog
- Canvas reference - shapes, transforms, interactive groups
- Styling reference - Color, Theme, StyleMap, Border, Shadow, Gradient
- Layout reference - sizing, alignment, containers
- Animation reference - transitions, springs, sequences
- Custom Widgets reference - extracting reusable widgets from patterns
Plushie.Undo,Plushie.Data,Plushie.Selection,Plushie.Route- state helper module docs