Canvas elements are reusable, typed building blocks for canvas drawing. Like custom widgets compose UI from built-in widgets, custom canvas elements compose visuals from built-in shapes. They produce tree nodes (same wire format as widgets) but operate in the canvas coordinate domain: positioned by x/y coordinates, drawn by the canvas renderer.
For the canvas system itself, see the Canvas guide and Canvas reference. For widgets that wrap canvas elements with behaviour and state, see Custom Widgets.
When to use what
| Situation | Approach |
|---|---|
| One-off visual in a view | Inline shapes in a canvas block |
| Reusable visual component | Custom canvas element |
| Visual with internal state | Custom widget with canvas view |
| Standard UI control | Built-in widget |
Inline shapes are fine for visuals you use once. When you find yourself copying the same group of shapes between views, extract an element.
Custom canvas elements are pure visual components: typed fields, validated input, reusable across canvases. No state, no event handling. They produce tree nodes like any other shape.
Custom widgets add behaviour on top. A widget can wrap a canvas element, add state management, handle events, and expose a high-level API. See From element to widget for the full progression.
Built-in widgets cover standard controls (buttons, inputs, sliders). Reach for canvas when you need custom visuals that built-in widgets do not provide.
Your first element
A color swatch: a filled rectangle with optional corner radius.
defmodule MyApp.Canvas.ColorSwatch do
use Plushie.Canvas.Element
element :color_swatch do
field :x, :float
field :y, :float
field :w, :float
field :h, :float
field :color, Plushie.Type.Color
field :radius, :float, default: 4
end
enduse Plushie.Canvas.Element imports the declaration macros and
registers the @before_compile hook that generates all the code.
element :color_swatch declares the type name. This becomes the
wire type string and the struct module identity.
field declarations define typed properties. Primitive shortcuts
(:float, :string, :boolean) and domain types
(Plushie.Type.Color) work exactly as they do in widget declarations.
Generated API
The macro generates:
new/1:ColorSwatch.new(opts)returns a struct with no ID (auto-assigned by the parent container)new/2:ColorSwatch.new(id, opts)returns a struct with explicit ID- Setters:
ColorSwatch.color(swatch, "#ff0000")for pipeline composition with_options/2: apply keyword optionsbuild/1: explicit conversion to aui_node()maptype_name/0: returns"color_swatch"encode/1: wire-format conversionPlushie.Tree.Nodeprotocol implementation
Using it in a canvas
Inside a canvas block, use the auto-ID form (the container assigns IDs automatically):
canvas "palette", width: 200, height: 50 do
layer "swatches" do
ColorSwatch.new(x: 0, y: 0, w: 40, h: 40, color: "#ef4444")
ColorSwatch.new(x: 50, y: 0, w: 40, h: 40, color: "#3b82f6")
ColorSwatch.new(x: 100, y: 0, w: 40, h: 40, color: "#22c55e")
end
endUse explicit IDs when you need stable identity for event matching or dynamic lists:
for {color, i} <- Enum.with_index(colors) do
ColorSwatch.new("swatch-#{i}", x: i * 50, y: 0, w: 40, h: 40, color: color)
endTree node output
ColorSwatch.new("red", x: 0, y: 0, w: 40, h: 40, color: "#ef4444") |> ColorSwatch.build() produces:
%{
id: "red",
type: "color_swatch",
props: %{x: 0, y: 0, w: 40, h: 40, color: "#ef4444", radius: 4},
children: []
}This is the same structure as any built-in shape node. The renderer sees a flat tree node with typed props.
Typed fields
Element fields use the same Plushie.Type system as widget fields.
When you declare field :color, Plushie.Type.Color, the macro:
- Generates a setter with a guard:
def color(el, value) when is_binary(value) or is_atom(value) - Validates input through
Color.cast/1(accepts hex strings, named atoms, RGB maps) - Generates the typespec for documentation
- Encodes via
Color.encode/1during wire conversion
# All valid, all normalize to hex:
ColorSwatch.new("s1", x: 0, y: 0, w: 40, h: 40, color: :red)
ColorSwatch.new("s2", x: 0, y: 0, w: 40, h: 40, color: "#ff0000")
ColorSwatch.new("s3", x: 0, y: 0, w: 40, h: 40, color: %{r: 255, g: 0, b: 0})
# Invalid, raises ArgumentError:
ColorSwatch.new("s4", x: 0, y: 0, w: 40, h: 40, color: 42)For simple elements, :any and :float are fine. Use domain types
when you want validation and documentation. See the
Custom Types reference for building your own.
Using elements
Three ways to use the same element.
In a canvas block (DSL)
canvas "palette", width: 200, height: 50 do
layer "swatches" do
ColorSwatch.new(x: 0, y: 0, w: 40, h: 40, color: "#ef4444")
ColorSwatch.new(x: 50, y: 0, w: 40, h: 40, color: "#3b82f6")
end
endPipeline construction
swatch =
ColorSwatch.new("red")
|> ColorSwatch.x(0)
|> ColorSwatch.y(0)
|> ColorSwatch.w(40)
|> ColorSwatch.h(40)
|> ColorSwatch.color("#ef4444")
|> ColorSwatch.radius(8)Helper function returning structs
defp swatch_row(colors) do
for {color, i} <- Enum.with_index(colors) do
ColorSwatch.new("swatch-#{i}",
x: i * 50, y: 0, w: 40, h: 40, color: color
)
end
endThis is particularly useful with import Plushie.Canvas.Shape for
helper functions outside canvas blocks. The returned structs are
valid tree nodes that the canvas renderer processes like any built-in
shape.
Positional arguments
Elements support positional constructor arguments for frequently used
fields. Declare them with positional:
element :color_swatch do
positional [:x, :y, :w, :h]
field :x, :float
field :y, :float
field :w, :float
field :h, :float
field :color, Plushie.Type.Color
field :radius, :float, default: 4
endThis generates new(id, x, y, w, h, opts \\ []) instead of the
default new(id, opts \\ []):
ColorSwatch.new("red", 0, 0, 40, 40, color: "#ef4444")The built-in elements (Rect, Circle, Text, Line) all use
positional arguments for their coordinate fields.
Composite elements
Elements that decompose into built-in shapes use a view/2 callback.
Define typed fields for the public API, and let view/2 produce the
primitive shapes.
A labeled dot: a filled circle with a text label underneath.
defmodule MyApp.Canvas.LabeledDot do
use Plushie.Canvas.Element
element :labeled_dot do
field :x, :float
field :y, :float
field :label, :string
field :color, Plushie.Type.Color, default: "#3b82f6"
field :radius, :float, default: 6.0
end
def view(_id, props) do
import Plushie.Canvas.Shape
[
circle(props.x, props.y, props.radius, fill: props.color),
text(props.x, props.y + props.radius + 12, props.label,
fill: "#333", size: 11, align_x: "center"
)
]
end
endThe view/2 callback receives the element ID and a map of the
declared fields. It returns a list of shape structs (or a single
struct). These replace the element node in the final tree.
When view/2 is not defined, the element encodes as a single typed
node (like the ColorSwatch above). When view/2 is defined, the
element is composite: it expands into its constituent shapes during
tree normalization.
Usage:
canvas "status", width: 200, height: 60 do
layer "dots" do
LabeledDot.new(x: 40, y: 20, label: "CPU", color: "#22c55e")
LabeledDot.new(x: 100, y: 20, label: "Memory", color: "#eab308")
LabeledDot.new(x: 160, y: 20, label: "Disk", color: "#ef4444")
end
endContainer elements
Elements with container: true accept children. This is for
structural grouping (transforms, clips) rather than visual
composition.
element :group, container: true do
field :transforms, :any
field :clip, :any
endContainer elements get push/2 and extend/2 for adding children
programmatically:
group =
Group.new("rotated")
|> Group.push(rect(0, 0, 40, 40, fill: "#ef4444"))
|> Group.push(circle(20, 20, 10, fill: "#fff"))The built-in Group and Interactive are the primary container
elements. Most custom elements will be non-container (visual
components that produce shapes, not structural wrappers).
Property types
Element fields support the full range of Plushie types. Some types are particularly useful in canvas contexts:
Stroke
A stroke descriptor for shape outlines:
field :border, :any # accepts Stroke structs
# Usage:
MyElement.new("el", border: stroke("#333", 2, cap: :round))Canvas gradients
Point-based linear gradients for canvas fills:
field :fill, :any # accepts color strings or Gradient structs
# Usage:
MyElement.new("el",
fill: linear_gradient({0, 0}, {100, 0}, [{0.0, "#3b82f6"}, {1.0, "#1d4ed8"}])
)ShapeStyle
Style overrides for hover/pressed/focus states on interactive
elements. Accepts fill, stroke, and opacity fields:
field :hover_style, :any # accepts ShapeStyle maps
# Usage in interactive wrapper:
interactive "btn", hover_style: %{fill: "#2563eb"} do
MyElement.new("el", ...)
endSee Plushie.Canvas.ShapeStyle, Plushie.Canvas.Stroke, and
Plushie.Canvas.Gradient.
Interaction and accessibility
Canvas elements are visual primitives. They do not handle events or
manage state. For interaction, wrap them in an interactive element.
If it responds to interaction, it needs an accessible name.
Clickable color swatch grid
defp swatch_grid(colors, selected) do
for {color, i} <- Enum.with_index(colors) do
x = rem(i, 6) * 44
y = div(i, 6) * 44
interactive "color-#{i}", x: x, y: y,
on_click: true,
on_hover: true,
cursor: :pointer,
focusable: true,
hover_style: %{opacity: 0.8},
a11y: %{role: :button, label: "Select #{color}"} do
ColorSwatch.new("swatch",
x: 0, y: 0, w: 40, h: 40,
color: color,
radius: if(color == selected, do: 0, else: 4)
)
# Selection indicator: sharp border around the selected swatch
if color == selected do
rect(0, 0, 40, 40, stroke: stroke("#000", 2))
end
end
end
endKey points:
interactivewraps the swatch and selection indicator in a clickable, focusable group.a11y: %{role: :button, label: ...}tells screen readers what this element is and what it does. Without this, the swatch is invisible to assistive technology.focusable: trueadds the element to the Tab order. Keyboard users can navigate and activate it with Space or Enter.hover_styleprovides visual feedback without event handling. The renderer applies it automatically.cursor: :pointersignals clickability to sighted users.
The click event arrives scoped under the canvas:
def update(model, %WidgetEvent{type: :click, id: "color-" <> index, scope: ["palette" | _]}) do
%{model | selected_color: Enum.at(model.colors, String.to_integer(index))}
endFocus ring
Interactive elements with focusable: true show a focus ring by
default. Customize it:
interactive "btn",
focusable: true,
show_focus_ring: true,
focus_ring_radius: 6,
focus_style: %{stroke: "#1d4ed8"} do
# shapes...
endSet show_focus_ring: false to suppress the default ring and draw
your own focus indicator in the view (driven by model state from
:focused/:blurred events).
From element to widget
The capstone pattern. Build a ProgressRing element for the visuals, then wrap it in a widget for the public API.
Step 1: The element (pure visuals)
The element handles drawing. Typed fields, validated input, a view callback that produces shape primitives. No state, no events.
defmodule MyApp.Canvas.ProgressRing do
use Plushie.Canvas.Element
element :progress_ring do
field :cx, :float
field :cy, :float
field :radius, :float
field :value, :float
field :max, :float, default: 100.0
field :track_color, :string, default: "#e5e7eb"
field :fill_color, :string, default: "#3b82f6"
field :thickness, :float, default: 6.0
end
def view(_id, props) do
import Plushie.Canvas.Shape
pct = min(props.value / props.max, 1.0)
[
# Track (full circle)
path([arc(props.cx, props.cy, props.radius, 0, 360)],
stroke: stroke(props.track_color, props.thickness, cap: :round)
),
# Value arc
path([arc(props.cx, props.cy, props.radius, -90, -90 + pct * 360)],
stroke: stroke(props.fill_color, props.thickness, cap: :round)
),
# Percentage label
text(props.cx, props.cy - 6, "#{round(pct * 100)}%",
fill: "#333", size: 14, align_x: "center"
)
]
end
endStep 2: The widget (wraps element in canvas, adds props)
The widget provides the public API: size, label, and value. It wraps the element in a canvas with accessibility annotations.
defmodule MyApp.ProgressRingWidget do
use Plushie.Widget
widget :progress_ring
field :value, :float, default: 0
field :max, :float, default: 100
field :size, :float, default: 120
field :color, :string, default: "#3b82f6"
field :label, :string, default: "Progress"
def view(id, props) do
import Plushie.UI
alias MyApp.Canvas.ProgressRing
pct = min(props.value / props.max, 1.0)
half = props.size / 2
radius = half - 8
canvas id, width: props.size, height: props.size,
a11y: %{role: :progress_bar, label: props.label,
value_now: props.value, value_min: 0, value_max: props.max} do
layer "ring" do
ProgressRing.new("ring",
cx: half, cy: half, radius: radius,
value: props.value, max: props.max,
fill_color: props.color
)
end
end
end
endStep 3: Usage in an app
def view(model) do
window "main", title: "Upload" do
column spacing: 16, padding: 24 do
MyApp.ProgressRingWidget.new("upload-progress",
value: model.upload_progress,
max: 100,
label: "Upload progress",
color: "#22c55e"
)
text("status", "#{round(model.upload_progress)}% complete")
end
end
endThe separation is clear: the element knows how to draw a progress ring. The widget knows how to present it as a UI component with accessibility, sizing, and a clean API. Neither needs to know about the other's internals.
Testing
Element encode output
Test that an element produces the expected tree node structure:
describe "ColorSwatch" do
test "encodes to correct wire format" do
node =
ColorSwatch.new("red", x: 0, y: 0, w: 40, h: 40, color: "#ef4444")
|> ColorSwatch.build()
assert node.type == "color_swatch"
assert node.props.color == "#ef4444"
assert node.props.radius == 4
end
test "validates color field" do
assert_raise ArgumentError, fn ->
ColorSwatch.new("bad", x: 0, y: 0, w: 40, h: 40, color: 42)
end
end
endComposite view output
Test that a composite element's view/2 returns the expected
primitives:
describe "ProgressRing" do
test "view produces track arc, value arc, and label" do
result = ProgressRing.view("ring", %{
cx: 60, cy: 60, radius: 45,
value: 50, max: 100,
track_color: "#e5e7eb", fill_color: "#3b82f6",
thickness: 6.0
})
assert length(result) == 3
assert %Plushie.Canvas.Path{} = Enum.at(result, 0)
assert %Plushie.Canvas.Path{} = Enum.at(result, 1)
assert %Plushie.Canvas.Text{} = Enum.at(result, 2)
end
endWidget integration
Test the full widget that wraps the element, using Plushie.Test.Case
with a real renderer:
defmodule MyApp.ProgressRingWidgetTest do
use Plushie.Test.WidgetCase, widget: MyApp.ProgressRingWidget
setup do
init_widget("ring", value: 75, max: 100)
end
test "renders the progress ring" do
assert_exists("#ring")
end
endSee the Testing reference for the full test helper API.
See also
- Canvas reference - shapes, transforms, interactive elements
- Canvas guide - building a canvas button
- Custom Widgets reference - wrapping elements in widgets with state and events
- Custom Types reference - building typed fields
- Composition Patterns - recipes for common UI patterns
Plushie.Canvas.Element- behaviour module docsPlushie.Canvas.Shape- builder functions for all built-in shapes