# Custom Canvas Elements
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](../guides/12-canvas.md)
and [Canvas reference](canvas.md). For widgets that wrap canvas elements
with behaviour and state, see [Custom Widgets](custom-widgets.md).
## 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](#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.
```elixir
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
end
```
`use 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 options
- **`build/1`**: explicit conversion to a `ui_node()` map
- **`type_name/0`**: returns `"color_swatch"`
- **`encode/1`**: wire-format conversion
- **`Plushie.Tree.Node`** protocol implementation
### Using it in a canvas
Inside a canvas block, use the auto-ID form (the container assigns
IDs automatically):
```elixir
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
end
```
Use explicit IDs when you need stable identity for event matching
or dynamic lists:
```elixir
for {color, i} <- Enum.with_index(colors) do
ColorSwatch.new("swatch-#{i}", x: i * 50, y: 0, w: 40, h: 40, color: color)
end
```
### Tree node output
`ColorSwatch.new("red", x: 0, y: 0, w: 40, h: 40, color: "#ef4444") |> ColorSwatch.build()` produces:
```elixir
%{
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:
1. Generates a setter with a guard: `def color(el, value) when is_binary(value) or is_atom(value)`
2. Validates input through `Color.cast/1` (accepts hex strings, named
atoms, RGB maps)
3. Generates the typespec for documentation
4. Encodes via `Color.encode/1` during wire conversion
```elixir
# 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](custom-types.md) for building your own.
## Using elements
Three ways to use the same element.
### In a canvas block (DSL)
```elixir
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
end
```
### Pipeline construction
```elixir
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
```elixir
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
end
```
This 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`:
```elixir
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
end
```
This generates `new(id, x, y, w, h, opts \\ [])` instead of the
default `new(id, opts \\ [])`:
```elixir
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.
```elixir
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
end
```
The `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:
```elixir
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
end
```
## Container elements
Elements with `container: true` accept children. This is for
structural grouping (transforms, clips) rather than visual
composition.
```elixir
element :group, container: true do
field :transforms, :any
field :clip, :any
end
```
Container elements get `push/2` and `extend/2` for adding children
programmatically:
```elixir
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:
```elixir
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:
```elixir
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:
```elixir
field :hover_style, :any # accepts ShapeStyle maps
# Usage in interactive wrapper:
interactive "btn", hover_style: %{fill: "#2563eb"} do
MyElement.new("el", ...)
end
```
See `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
```elixir
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
end
```
Key points:
- **`interactive`** wraps 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: true`** adds the element to the Tab order. Keyboard
users can navigate and activate it with Space or Enter.
- **`hover_style`** provides visual feedback without event handling.
The renderer applies it automatically.
- **`cursor: :pointer`** signals clickability to sighted users.
The click event arrives scoped under the canvas:
```elixir
def update(model, %WidgetEvent{type: :click, id: "color-" <> index, scope: ["palette" | _]}) do
%{model | selected_color: Enum.at(model.colors, String.to_integer(index))}
end
```
### Focus ring
Interactive elements with `focusable: true` show a focus ring by
default. Customize it:
```elixir
interactive "btn",
focusable: true,
show_focus_ring: true,
focus_ring_radius: 6,
focus_style: %{stroke: "#1d4ed8"} do
# shapes...
end
```
Set `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.
```elixir
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
end
```
### Step 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.
```elixir
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
end
```
### Step 3: Usage in an app
```elixir
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
end
```
The 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:
```elixir
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
end
```
### Composite view output
Test that a composite element's `view/2` returns the expected
primitives:
```elixir
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
end
```
### Widget integration
Test the full widget that wraps the element, using `Plushie.Test.Case`
with a real renderer:
```elixir
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
end
```
See the [Testing reference](testing.md) for the full test helper API.
## See also
- [Canvas reference](canvas.md) - shapes, transforms, interactive
elements
- [Canvas guide](../guides/12-canvas.md) - building a canvas button
- [Custom Widgets reference](custom-widgets.md) - wrapping elements
in widgets with state and events
- [Custom Types reference](custom-types.md) - building typed fields
- [Composition Patterns](composition-patterns.md) - recipes for
common UI patterns
- `Plushie.Canvas.Element` - behaviour module docs
- `Plushie.Canvas.Shape` - builder functions for all built-in shapes