The canvas system provides 2D drawing with typed shape structs, transforms, interactive groups, and accessibility support. Unlike layout widgets that compose children, canvas draws shapes on a surface: rectangles, circles, lines, paths, text, images, and SVG.
For a narrative introduction, see the Canvas guide.
Canvas widget
The canvas is a widget that contains named layers of shapes. It participates in the layout system like any other widget (has width, height, background) but its content is drawn shapes, not child widgets.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
width | Length | :fill | Canvas width |
height | Length | 200 | Canvas height (pixels) |
background | Color | n/a | Canvas background colour |
on_press | boolean | false | Emit :press events |
on_release | boolean | false | Emit :release events |
on_move | boolean | false | Emit :move events |
on_scroll | boolean | false | Emit :scroll events |
event_rate | integer | n/a | Max events/sec for canvas-level events |
a11y | map | n/a | Accessibility overrides. See Accessibility. |
Layers
Canvas content is organised into named layers. Each layer is drawn independently:
canvas "chart", width: 400, height: 200 do
layer "background" do
rect(0, 0, 400, 200, fill: "#f5f5f5")
end
layer "data" do
rect(10, 50, 80, 150, fill: "#3b82f6")
rect(110, 100, 80, 100, fill: "#22c55e")
end
layer "labels" do
text(50, 190, "A", fill: "#333", size: 12)
text(150, 190, "B", fill: "#333", size: 12)
end
endDrawing order: layers are drawn in alphabetical order by name.
"background" draws before "data" which draws before "labels".
This is the z-ordering mechanism. Name your layers to control which
appears on top.
Independent caching: each layer maps to a separate cache on the renderer side. When a layer's shapes change, only that layer is re-tessellated. Unchanged layers are drawn from cache. For canvases with many shapes, splitting into layers (static background, dynamic data, interactive controls) significantly reduces rendering work.
Shape catalog
All shapes are builder functions in Plushie.Canvas.Shape that return
typed structs. Use them inside layer or group blocks.
| Function | Struct | Required args | Key options |
|---|---|---|---|
rect/4 | Rect | x, y, w, h | fill, stroke, opacity, radius |
circle/3 | Circle | x, y, r | fill, stroke, opacity |
line/4 | Line | x1, y1, x2, y2 | stroke, opacity |
text/3 | CanvasText | x, y, content | fill, size, font, align_x, align_y, opacity |
path/1 | Path | commands | fill, stroke, opacity, fill_rule |
image/5 | CanvasImage | source, x, y, w, h | rotation, opacity |
svg/5 | CanvasSvg | source, x, y, w, h | n/a |
Common shape options:
fill: hex colour string, named atom, or gradient (linear_gradient). Fills the shape interior.stroke: a stroke descriptor (see Strokes). Draws the shape outline.opacity: 0.0 (transparent) to 1.0 (opaque).
Path commands
Functions that return path command data for use with path/1:
| Function | Arguments | Description |
|---|---|---|
move_to/2 | x, y | Move pen without drawing |
line_to/2 | x, y | Straight line to point |
bezier_to/6 | cp1x, cp1y, cp2x, cp2y, x, y | Cubic bezier curve |
quadratic_to/4 | cpx, cpy, x, y | Quadratic bezier curve |
arc/5 | cx, cy, r, start_angle, end_angle | Arc by centre |
arc_to/5 | x1, y1, x2, y2, radius | Tangent arc |
ellipse/7 | cx, cy, rx, ry, rotation, start, end | Ellipse arc |
rounded_rect/5 | x, y, w, h, radius | Rounded rectangle path |
close/0 | n/a | Close the current subpath |
path([
move_to(10, 0),
line_to(20, 20),
line_to(0, 20),
close()
], fill: "#22c55e")Strokes
stroke/3 creates a stroke descriptor:
stroke("#333", 2) # colour and width
stroke("#333", 2, cap: :round) # with line cap
stroke("#333", 2, dash: {[5, 3], 0}) # dashed line| Option | Values | Default | Purpose |
|---|---|---|---|
cap: | :butt, :round, :square | :butt | Line end style |
join: | :miter, :round, :bevel | :miter | Corner join style |
dash: | {segments, offset} | n/a | Dash pattern (segment lengths + initial offset) |
Strokes support the Buildable do-block syntax:
rect(0, 0, 100, 50) do
fill "#3b82f6"
stroke do
color "#333"
width 2
cap :round
end
endSee Plushie.Canvas.Shape.Stroke and Plushie.Canvas.Shape.Dash.
Gradients (canvas)
linear_gradient/3 creates a gradient for use as a fill in canvas
shapes. This is different from Plushie.Type.Gradient.linear/2 (which
uses an angle for widget backgrounds). Canvas gradients use coordinate
pairs:
rect(0, 0, 200, 50,
fill: linear_gradient({0, 0}, {200, 0}, [
{0.0, "#3b82f6"},
{1.0, "#1d4ed8"}
])
)The first argument is the start point {x, y}, the second is the end
point {x, y}. Stops are {offset, colour} tuples where offset is
0.0-1.0.
See Plushie.Canvas.Shape.LinearGradient.
SVG and images
Embed external visuals in canvas layers:
layer "icons" do
svg(File.read!("priv/icons/save.svg"), 10, 8, 20, 20)
image("priv/images/logo.png", 50, 8, 32, 32, opacity: 0.8)
endsvg/5 takes an SVG source string (not a file path; read the file
first). image/5 takes a file path string.
Combined with interactive groups, SVG content can be made clickable, hoverable, and keyboard-accessible:
group "save", on_click: true, cursor: :pointer,
focusable: true, a11y: %{role: :button, label: "Save"} do
svg(File.read!("priv/icons/save.svg"), 0, 0, 36, 36)
endTransforms
Transforms apply to groups only, not individual shapes. They are applied in declaration order.
| Function | Arguments | Description |
|---|---|---|
translate/2 | x, y | Move the group |
rotate/1 | angle | Rotate around origin (degrees by default) |
rotate/1 | degrees: n or radians: n | Explicit unit |
scale/1 | factor | Uniform scale |
scale/2 | x, y | Non-uniform scale |
group x: 100, y: 50 do
rotate(45) # 45 degrees
rotate(radians: 0.785) # explicit radians
rect(0, 0, 40, 40, fill: "#ef4444")
endThe x: and y: keyword options on groups desugar to a leading
translate.
Clips
clip/4 restricts drawing to a rectangular region. One clip per group.
group do
clip(0, 0, 80, 80)
circle(40, 40, 60, fill: "#3b82f6") # clipped to 80x80 square
endSee Plushie.Canvas.Shape.Clip.
Interactive groups
Plushie.Canvas.Shape.Group is the only shape type that supports
interactivity. Any collection of shapes can become clickable, hoverable,
draggable, or keyboard-focusable by wrapping them in an interactive
group.
Interaction props
| Prop | Type | Default | Purpose |
|---|---|---|---|
on_click | boolean | false | Enable :click events (scoped under canvas ID) |
on_hover | boolean | false | Enable :enter/:exit events |
draggable | boolean | false | Enable :drag/:drag_end events |
drag_axis | "x" / "y" / "both" | n/a | Constrain drag direction (unconstrained when not set) |
drag_bounds | DragBounds | n/a | Limit drag region (%{min_x, max_x, min_y, max_y}) |
focusable | boolean | false | Add to Tab order for keyboard navigation |
cursor | atom/string | n/a | Cursor style on hover (:pointer, :grab, etc.) |
tooltip | string | n/a | Tooltip text on hover |
hit_rect | HitRect | n/a | Custom hit testing region (%{x, y, w, h}) |
Visual feedback props
| Prop | Type | Purpose |
|---|---|---|
hover_style | ShapeStyle | Override fill, stroke, opacity on hover |
pressed_style | ShapeStyle | Override while pressed |
focus_style | ShapeStyle | Override when keyboard-focused |
show_focus_ring | boolean | Show/hide the default focus indicator |
focus_ring_radius | number | Corner radius for the focus ring |
ShapeStyle accepts: fill (colour or gradient), stroke (colour or
stroke descriptor), opacity (0.0-1.0). Only specified fields are
overridden; others inherit from the shape's base values.
Accessibility
Canvas is a raw drawing surface with no inherent semantic knowledge.
Interactive groups need explicit a11y annotations for screen reader
and keyboard support:
group "hue-ring",
on_click: true,
focusable: true,
a11y: %{role: :slider, label: "Hue", value: "#{round(hue)} degrees"} do
# ... shapes
endWithout a11y annotations, interactive groups are invisible to
assistive technology. See the Accessibility reference
for the full set of fields and roles.
Canvas events
All canvas events arrive as Plushie.Event.WidgetEvent structs.
Canvas-level events
Require on_press/on_release/on_move/on_scroll props on the
canvas widget. These use the unified pointer event model with device
type and modifier information. Mouse, touch, and pen input all produce
the same event types with full hit testing, drag, and click support.
The pointer field identifies the device, finger carries the touch
finger ID (nil for mouse), and modifiers carries the current modifier
key state:
| Event type | Data fields |
|---|---|
:press | x, y, button, pointer, finger, modifiers |
:release | x, y, button, pointer, finger, modifiers |
:move | x, y, pointer, finger, modifiers |
:scroll | x, y, delta_x, delta_y, pointer, modifiers |
Touch events use pointer: :touch with button: :left and include
a finger integer identifying the touch point:
# Handle touch press on canvas
def update(model, %WidgetEvent{type: :press, id: "drawing",
data: %{x: x, y: y, pointer: :touch, finger: 0}}) do
start_stroke(model, x, y)
end
# Handle touch drag
def update(model, %WidgetEvent{type: :move, id: "drawing",
data: %{x: x, y: y, pointer: :touch, finger: 0}}) do
continue_stroke(model, x, y)
endElement-level events
Require interaction props on interactive groups:
| Event type | Trigger | Data fields |
|---|---|---|
:click | on_click: true | Scoped under canvas ID (see below) |
:enter | on_hover: true | n/a |
:exit | on_hover: true | n/a |
:drag | draggable: true | x, y, delta_x, delta_y |
:drag_end | draggable: true | x, y |
:key_press | focusable: true | key, modifiers, text |
:key_release | focusable: true | key, modifiers |
:focused | focusable: true | n/a |
:blurred | focusable: true | n/a |
Canvas element clicks are regular :click events. The renderer
emits them with the element's scoped ID (e.g., "my-canvas/handle"),
and the SDK's scoped ID system splits this into id: "handle" with
scope: ["my-canvas", window_id]. Match them like any scoped click:
def update(model, %WidgetEvent{type: :click, id: "handle", scope: ["my-canvas" | _]}) do
# handle canvas element click
endOther element events (enter, leave, drag, key, focus) use standard generic event families shared across all widget types.
Pointer events in custom widgets
Canvas-level pointer events (:press, :release, :move,
:scroll) are delivered through the widget handler pipeline
like any other event. If a custom widget's handle_event/2 does not
intercept them, they reach the parent app's update/2.
To transform a pointer event before it reaches update/2, handle it in
handle_event/2 and emit a new event via {:emit, family, data}.
Element scoping
Canvas element IDs participate in the standard scoped ID system. The canvas widget's ID creates a scope, and interactive group IDs within it are scoped under it:
canvas "drawing" -> "drawing"
group "handle" ... -> "drawing/handle"Events arrive with the group's local ID, the canvas in the scope, and the window ID at the end:
%WidgetEvent{type: :click, id: "handle", scope: ["drawing", "main"], window_id: "main"}Examples
Toggle switch
An interactive group with state-driven thumb position and accessibility annotations:
canvas "switch", width: 64, height: 32 do
layer "track" do
group "toggle", on_click: true, cursor: :pointer,
a11y: %{role: :switch, label: "Dark mode", toggled: model.dark} do
rect(0, 0, 64, 32, fill: if(model.dark, do: "#3b82f6", else: "#ddd"), radius: 16)
circle(if(model.dark, do: 44, else: 20), 16, 12, fill: "#fff")
end
end
endBar chart
Focusable bars with accessibility annotations for screen reader navigation. Each bar reports its position in the data set:
canvas "chart", width: 300, height: 200 do
layer "bars" do
for {value, i} <- Enum.with_index(model.data) do
x = i * 40 + 10
h = value * 2
group "bar-#{i}", x: x, y: 200 - h, focusable: true,
tooltip: "#{value}",
a11y: %{role: :image, label: "Value: #{value}",
position_in_set: i + 1, size_of_set: length(model.data)} do
rect(0, 0, 30, h, fill: "#3b82f6")
end
end
end
endCanvas with widget overlay
Layer a canvas (custom visuals) under a text_input (standard widget)
using stack. The transparent background on the input lets the canvas
decoration show through:
stack width: 200, height: 40 do
canvas "bg", width: 200, height: 40 do
layer "decor" do
rect(0, 0, 200, 40, fill: "#f5f5f5", radius: 8)
end
end
text_input("input", model.value,
placeholder: "Search...",
style: StyleMap.new() |> StyleMap.background(:transparent)
)
endSee also
Plushie.Canvas.Shape- all builder functionsPlushie.Widget.Canvas- canvas widget props- Canvas guide - building a canvas button for the pad
- Custom Widgets guide - canvas-based custom widgets
- Accessibility reference - canvas accessibility annotations
- Styling reference -
Plushie.Type.Gradient.linear/2for widget background gradients (different from canvaslinear_gradient/3)