All built-in widgets are available via import Plushie.UI. Each widget has a corresponding typed builder module under Plushie.Widget.* for programmatic use.

Widget catalog

Layout

DSL macroModuleDescription
windowPlushie.Widget.WindowTop-level window with title, size, position, theme
columnPlushie.Widget.ColumnArranges children vertically
rowPlushie.Widget.RowArranges children horizontally
containerPlushie.Widget.ContainerSingle-child wrapper for styling, scoping, alignment
scrollablePlushie.Widget.ScrollableScrollable viewport around child content
stackPlushie.Widget.StackLayers children on top of each other (z-axis)
gridPlushie.Widget.GridFixed-column or fluid grid layout
keyed_columnPlushie.Widget.KeyedColumnVertical layout with ID-based diffing for dynamic lists
responsivePlushie.Widget.ResponsiveEmits resize events for adaptive layouts
pinPlushie.Widget.PinPositions child at absolute coordinates
floatingPlushie.Widget.FloatingApplies translate/scale transforms to child
spacePlushie.Widget.SpaceInvisible spacer

Full prop tables for all layout containers are in the Layout reference.

Input

DSL macroMacro formEvents
buttonbutton(id, label):click
text_inputtext_input(id, value, opts):input, :submit, :paste
text_editortext_editor(id, content, opts):input
checkboxcheckbox(id, checked, opts):toggle (value: boolean)
togglertoggler(id, checked, opts):toggle (value: boolean)
radioradio(id, value, selected, opts):select
sliderslider(id, range, value, opts):slide, :slide_release
vertical_slidervertical_slider(id, range, value, opts):slide, :slide_release
pick_listpick_list(id, options, selected, opts):select, :open, :close
combo_boxcombo_box(id, value, opts):input, :select, :open, :close

button is the simplest interactive widget. The label is the second positional argument. Emits :click on press.

text_input is a single-line editable field. Emits :input on every keystroke with the full text as value. Emits :submit on Enter when on_submit: true is set. Emits :paste when on_paste: true is set.

text_editor is a multi-line editable area with syntax highlighting support (highlight_syntax: "ex"). The content argument seeds the initial text. Holds renderer-side state (cursor, selection, scroll).

checkbox / toggler are boolean toggles. Both emit :toggle with the new boolean value. checkbox shows a box; toggler shows a switch. Add label: for accessible text.

slider / vertical_slider are range inputs. range is a {min, max} tuple. Emits :slide continuously while dragging and :slide_release when the drag ends with the final value. Supports circular_handle: true for a round handle, with handle_radius (float) controlling the circle's radius.

pick_list is a dropdown selection. options is a list of strings. selected is the currently selected value (or nil). Emits :select when an option is chosen.

combo_box is a searchable dropdown. Combines a text input with a filtered option list. Holds renderer-side state (search text, open state). Emits :input on typing and :select on option selection.

radio is a one-of-many selection. value is the option this radio represents; selected is the currently selected value from the model. The radio is checked when value == selected. Emits :select with the radio's value.

Display

DSL macroMacro formDescription
texttext(content) or text(id, content)Static text display
rich_textrich_text(id, spans)Styled text with per-span formatting
rulerule() or rule(id)Horizontal or vertical divider
progress_barprogress_bar(id, range, value)Progress indicator
tooltiptooltip(id, tip, opts) do child endPopup tip on hover
imageimage(id, source, opts)Raster image from file path
svgsvg(id, source, opts)Vector image from SVG file
qr_codeqr_code(id, data, opts)QR code from a data string
markdownmarkdown(content) or markdown(id, content)Rendered markdown
canvascanvas(id, opts) do layers endDrawing surface with named layers

text supports auto-ID (text("Hello")) and explicit-ID (text("greeting", "Hello")). Key props: size, color, font, wrapping, shaping, align_x, align_y.

rich_text displays styled text with individually formatted spans. Each span is a map with optional keys: text (content), size, color, font, link (clickable URL), underline, strikethrough, line_height, padding, and highlight (background with optional border). Example:

rich_text("greeting", [
  %{text: "Hello, ", size: 16},
  %{text: "world", size: 16, color: "#3b82f6", underline: true},
  %{text: "!", size: 16}
])

tooltip wraps a child widget. The child is the anchor; tip is the tooltip text. Props: position (:top, :bottom, :left, :right, :follow_cursor), gap.

image renders a raster image. Two source modes:

  • Path-based (preferred): image("photo", "path/to/file.png"). The renderer loads the file directly. No wire transfer.
  • Handle-based: image("photo", handle: "avatar"). References an in-memory image created via Command.create_image/2.

Key props: content_fit, filter_method, width, height, opacity, rotation (degrees), border_radius, scale, crop (%{x, y, width, height}), alt (accessible label).

In-memory image handles:

# Create from encoded PNG/JPEG bytes:
{model, Command.create_image("avatar", png_bytes)}

# Create from raw RGBA pixels:
{model, Command.create_image_rgba("avatar", 512, 512, rgba_pixels)}

# Reference in view:
image("display", handle: "avatar")

# Update pixels:
{model, Command.update_image("avatar", new_pixels)}

# Delete:
{model, Command.delete_image("avatar")}

Handle-based images send the entire payload over the wire in a single message, which blocks all other protocol traffic for large images. Prefer path-based loading when the file exists on disk. A chunked streaming alternative for large dynamic images is planned.

canvas contains named layers of shapes. See the Canvas reference.

Table

Plushie.Widget.Table

table displays structured data in rows and columns with sortable headers, row selection highlighting, and optional striped backgrounds. Rows are real tree children, so adding, removing, or reordering rows produces minimal wire patches (LIS-based diffing) instead of re-sending the entire dataset.

The table ID is optional. Pass one when you need to match sort or row_click events:

table "users", columns: cols, selected: Selection.to_list(sel) do
  for user <- model.users do
    table_row user.id do
      cell "name", text(user.name)
      cell "email", text(user.email)
      cell "actions", button("del-#{user.id}", "Delete")
    end
  end
end

For simple text-only tables, the rows: shorthand avoids the do-block entirely:

table columns: cols, rows: model.users

Both forms are mutually exclusive. The rows: shorthand expands to table_row/table_cell children during build.

Columns

Column definitions are maps passed via the columns: prop. Every column needs a key (matching the row data field) and a label (header text):

columns: [
  %{key: :name, label: "Name", sortable: true, width: :fill},
  %{key: :email, label: "Email"},
  %{key: :role, label: "Role", align: "center", width: 120}
]
KeyTypeDefaultDescription
keyatom or stringrequiredRow data lookup key
labelstringrequiredHeader display text
sortablebooleanfalseHeader clickable for sort
widthLength:fillColumn width
align"left" "center" "right""left"Cell alignment

All column keys must be the same type (all atoms or all strings).

Rich cells

Inside a table_row, each cell maps to a column by key. Cells can contain any widget, not just text:

table_row user.id do
  cell "name", text(user.name, font: Font.new() |> Font.weight(:bold))
  cell "status", progress_bar("prog", {0, 100}, user.progress)
  cell "actions" do
    button("edit-#{user.id}", "Edit")
    button("del-#{user.id}", "Delete")
  end
end

Sorting

Mark columns as sortable: true. Clicking a sortable header emits a :sort event with the column key as the value. The table displays the sort indicator but does not reorder rows. Sort in your model:

def update(model, %WidgetEvent{type: {:table, :sort}, id: "users", value: col}) do
  dir = if model.sort_by == col and model.sort_order == :asc, do: :desc, else: :asc
  sorted = Enum.sort_by(model.users, & &1[col], if(dir == :asc, do: :asc, else: :desc))
  %{model | users: sorted, sort_by: col, sort_order: dir}
end

Selection

Selection is app-managed. Pass selected row IDs via the selected: prop; the renderer highlights those rows. Handle :row_click events to update selection state using Plushie.Selection:

def update(model, %WidgetEvent{type: {:table, :row_click}, id: row_id}) do
  %{model | selection: Selection.toggle(model.selection, row_id)}
end

def view(model) do
  table "users",
    columns: cols,
    selected: Selection.to_list(model.selection),
    striped: true do
    ...
  end
end

Props

PropTypeDefaultDescription
columns[map]Column definitions (see above)
rows[map]Data shorthand: text-only rows
headerbooleantrueShow header row
selected[string]Row IDs to highlight
stripedbooleanfalseAlternate row backgrounds
separatorfloat1.0Divider thickness (0.0 to hide)
separator_colorColorDivider colour
sort_bystringCurrently sorted column key
sort_order:asc/:descSort direction
widthLength:fillTable width
heightLengthTable height (scrollable when set)
paddingPaddingCell internal padding
header_text_sizenumberHeader font size
row_text_sizenumberBody font size (data shorthand)

Pane grid

Plushie.Widget.PaneGrid

Resizable tiled pane layout. Children are keyed by their node ID and rendered as individual panes. The renderer manages internal pane sizes and arrangement, persisted across re-renders by the widget's ID.

pane_grid "editor", panes: ["left", "right"], spacing: 2 do
  text_editor "left", model.left_source
  text_editor "right", model.right_source
end

Pane grid props

PropTypeDefaultPurpose
panes[string]n/aList of pane identifiers (atoms coerced to strings)
spacingnumber2Space between panes in pixels
widthLength:fillGrid width
heightLength:fillGrid height
min_sizenumber10Minimum pane size in pixels
divider_colorColorn/aColor for the split divider
divider_widthnumbern/aDivider thickness in pixels
leewaynumbern/aGrabbable area around dividers in pixels
split_axis:horizontal / :verticaln/aControls the initial split direction

Pane grid events

EventDataDescription
:pane_clickedn/aEmitted when a pane is selected
:pane_resized%{split: string, ratio: float}Emitted when a split divider is moved
:pane_draggedn/aEmitted during pane drag operations
:pane_focus_cyclen/aEmitted on F6/Shift+F6 focus cycling

Usage patterns

Pane identifiers in the panes list determine which children map to which pane. Each child's ID must match a pane identifier. Atom pane identifiers are automatically coerced to strings.

The pane grid holds renderer-side state (pane sizes and arrangement). If the widget's ID changes, this state resets. An explicit string ID is required (compile-time error if omitted).

For accessibility, wrap the pane grid in a container with an explicit role and label:

container "editor-panes" do
  a11y do
    role :group
    label "Editor panes"
  end

  pane_grid "grid", panes: ["left", "right"] do
    text_editor "left", model.left
    text_editor "right", model.right
  end
end

Interaction wrappers

pointer_area

Wraps a single child and captures pointer events from mouse, touch, and pen input. Use for right-click menus, hover detection, drag tracking, scroll capture, and custom cursor styles. All events use the unified pointer model: the pointer field (:mouse, :touch, :pen) identifies the device, and modifiers carries the current modifier key state for shift-click, ctrl-drag, and similar patterns.

PropTypePurpose
cursorcursor atomMouse cursor on hover
on_pressatom / stringLeft button press event tag
on_releaseatom / stringLeft button release event tag
on_right_pressbooleanEnable right button press
on_right_releasebooleanEnable right button release
on_middle_pressbooleanEnable middle button press
on_middle_releasebooleanEnable middle button release
on_double_clickbooleanEnable double-click
on_enterbooleanEnable cursor enter
on_exitbooleanEnable cursor exit
on_movebooleanEnable cursor move (coalescable)
on_scrollbooleanEnable scroll wheel (coalescable)
event_rateintegerMax events/sec for move and scroll
a11ymapAccessibility overrides

Cursor values: :pointer, :grab, :grabbing, :crosshair, :text, :move, :not_allowed, :progress, :wait, :help, :resizing_horizontally, :resizing_vertically, and others.

Move and scroll events carry pointer (device type) and modifiers (current modifier key state):

pointer_area "canvas-area",
  on_move: true,
  on_press: :area_press,
  on_scroll: true,
  cursor: :crosshair do
  canvas "drawing", width: 400, height: 300 do
    # ...
  end
end

# Shift-click for multi-select
def update(model, %WidgetEvent{type: :press, id: "canvas-area",
    data: %{pointer: :mouse, modifiers: %{shift: true}}}) do
  add_to_selection(model)
end

# Ctrl-drag for panning
def update(model, %WidgetEvent{type: :move, id: "canvas-area",
    data: %{x: x, y: y, modifiers: %{ctrl: true}}}) do
  pan_canvas(model, x, y)
end

# Scroll with pointer type
def update(model, %WidgetEvent{type: :scroll, id: "canvas-area",
    data: %{delta_y: dy, pointer: :mouse}}) do
  zoom(model, dy)
end

sensor

Wraps a single child and emits events when the child's size changes or when it enters/exits visibility. Useful for responsive layouts, lazy loading, and intersection observation.

PropTypePurpose
delayintegerDelay (ms) before emitting events
anticipatenumberDistance (px) to anticipate visibility
on_resizeatom / stringEvent tag for resize events
event_rateintegerMax events/sec for resize
a11ymapAccessibility overrides

Events: :resize with %{width: w, height: h} in data.

overlay

Positions the second child as a floating overlay relative to the first child (anchor). Exactly two children required.

PropTypeDefaultPurpose
position:below / :above / :left / :right:belowOverlay position
gapnumber0Space between anchor and overlay
offset_xnumber0Horizontal offset after positioning
offset_ynumber0Vertical offset after positioning
flipbooleanfalseAuto-flip when overlay overflows viewport
align:start / :center / :end:centerCross-axis alignment
widthLengthn/aOverlay container width
a11ymapn/aAccessibility overrides

The overlay renders above all other content at the positioned location. See the Composition Patterns reference for a popover menu example.

themer

Applies a different theme to its children. Single child, single prop:

themer "dark-section", theme: :dark do
  container padding: 12 do
    text("info", "This section uses the dark theme")
  end
end

Common props

Most widgets support a subset of these cross-cutting props:

  • :style - visual appearance. Accepts a preset atom (e.g. :primary, :danger) or a StyleMap. See the Styling reference.
  • :a11y - accessibility attributes. See the Accessibility reference.
  • :width / :height - sizing. Accepts :fill, :shrink, {:fill_portion, n}, or a pixel number. See the Layout reference.
  • :event_rate - max events per second for high-frequency events. Supported on slider, vertical_slider, pointer_area, sensor, canvas, and pane_grid.

Renderer-side state

Some widgets hold state in the renderer that persists across re-renders. If their ID changes, this state resets:

  • text_input - cursor position, selection, undo history
  • text_editor - cursor, selection, scroll position, undo
  • combo_box - search text, open/closed state
  • scrollable - scroll position
  • pane_grid - pane sizes and arrangement

These widgets require explicit string IDs. scrollable and pane_grid produce compile-time errors if you forget the ID.

keyed_column vs column

Use column for static layouts. Use keyed_column when children are dynamic (added, removed, reordered). It diffs by child ID instead of position, preserving widget state across list changes. Same props as column minus align_x, clip, and wrap.

Auto-ID vs explicit ID

Layout containers (column, row, stack, grid, keyed_column, responsive) and some display widgets (text, markdown, rule, space, progress_bar) support auto-generated IDs. Omit the ID and one is generated from the call site.

All interactive and stateful widgets require explicit string IDs for event routing and state persistence. See the DSL reference for the full breakdown.

Animatable props

Numeric props support renderer-side transitions via transition(), spring(), and loop(). Commonly animated: max_width, max_height, opacity, translate_x, translate_y, scale. See the Animation reference.

Prop value types

These prop types are used across multiple widgets. The full styling types (Color, Theme, StyleMap, Border, Shadow, Gradient) are in the Styling reference. Layout types (Length, Padding, Alignment) are in the Layout reference.

Font

Used by: text, rich_text, text_input, text_editor.

ValueMeaning
:defaultSystem default proportional font
:monospaceSystem monospace font
"Family Name"Specific font family (must be loaded via settings/0)

The full font spec struct (Plushie.Type.Font) also supports weight: (:thin through :black), style: (:normal, :italic, :oblique), and stretch: (:ultra_condensed through :ultra_expanded).

Shaping

Used by: text, rich_text, text_input, text_editor.

ValueMeaning
:basicSimple left-to-right shaping (fastest)
:advancedFull Unicode shaping (ligatures, RTL, complex scripts)
:autoLet the renderer decide based on content

Wrapping

Used by: text, rich_text.

ValueMeaning
:noneNo wrapping (text overflows)
:wordBreak at word boundaries
:glyphBreak at any character
:word_or_glyphTry word boundaries first, fall back to glyph

Content fit

Used by: image, svg.

ValueMeaning
:containScale to fit within bounds, preserving aspect ratio
:coverScale to fill bounds, cropping if needed
:fillStretch to fill bounds exactly (may distort)
:noneNo scaling (original size)
:scale_downLike :contain but never scales up

Filter method

Used by: image.

ValueMeaning
:nearestPixel-perfect interpolation (blocky, good for pixel art)
:linearSmooth interpolation (good for photos)

Tooltip position

Used by: tooltip.

ValueMeaning
:topAbove the widget
:bottomBelow the widget
:leftLeft of the widget
:rightRight of the widget
:follow_cursorFollows the mouse cursor

Scroll direction

Used by: scrollable.

ValueMeaning
:verticalVertical scrolling (default)
:horizontalHorizontal scrolling
:bothBidirectional scrolling

Scroll anchor

Used by: scrollable.

ValueMeaning
:startAnchor at the top/left (default)
:endAnchor at the bottom/right

Auto scroll

Used by: scrollable.

When auto_scroll: true is set, the scrollable automatically scrolls to reveal new content appended at the anchor end. This is useful for chat logs, terminal output, and other append-only content where the user expects to see the latest entries without manual scrolling.

scrollable "log", direction: :vertical, anchor: :end, auto_scroll: true do
  column spacing: 4 do
    for entry <- model.log_entries do
      text(entry.id, entry.text)
    end
  end
end

When the user manually scrolls away from the anchor, auto-scroll pauses to avoid fighting the user's position. It resumes when the user scrolls back to the anchor end.

See also