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

Plushie.Widget.Canvas

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

PropTypeDefaultPurpose
widthLength:fillCanvas width
heightLength200Canvas height (pixels)
backgroundColorn/aCanvas background colour
on_pressbooleanfalseEmit :press events
on_releasebooleanfalseEmit :release events
on_movebooleanfalseEmit :move events
on_scrollbooleanfalseEmit :scroll events
event_rateintegern/aMax events/sec for canvas-level events
a11ymapn/aAccessibility 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
end

Drawing 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.

FunctionStructRequired argsKey options
rect/4Rectx, y, w, hfill, stroke, opacity, radius
circle/3Circlex, y, rfill, stroke, opacity
line/4Linex1, y1, x2, y2stroke, opacity
text/3CanvasTextx, y, contentfill, size, font, align_x, align_y, opacity
path/1Pathcommandsfill, stroke, opacity, fill_rule
image/5CanvasImagesource, x, y, w, hrotation, opacity
svg/5CanvasSvgsource, x, y, w, hn/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:

FunctionArgumentsDescription
move_to/2x, yMove pen without drawing
line_to/2x, yStraight line to point
bezier_to/6cp1x, cp1y, cp2x, cp2y, x, yCubic bezier curve
quadratic_to/4cpx, cpy, x, yQuadratic bezier curve
arc/5cx, cy, r, start_angle, end_angleArc by centre
arc_to/5x1, y1, x2, y2, radiusTangent arc
ellipse/7cx, cy, rx, ry, rotation, start, endEllipse arc
rounded_rect/5x, y, w, h, radiusRounded rectangle path
close/0n/aClose 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
OptionValuesDefaultPurpose
cap::butt, :round, :square:buttLine end style
join::miter, :round, :bevel:miterCorner join style
dash:{segments, offset}n/aDash 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
end

See 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)
end

svg/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)
end

Transforms

Transforms apply to groups only, not individual shapes. They are applied in declaration order.

FunctionArgumentsDescription
translate/2x, yMove the group
rotate/1angleRotate around origin (degrees by default)
rotate/1degrees: n or radians: nExplicit unit
scale/1factorUniform scale
scale/2x, yNon-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")
end

The 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
end

See 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

PropTypeDefaultPurpose
on_clickbooleanfalseEnable :click events (scoped under canvas ID)
on_hoverbooleanfalseEnable :enter/:exit events
draggablebooleanfalseEnable :drag/:drag_end events
drag_axis"x" / "y" / "both"n/aConstrain drag direction (unconstrained when not set)
drag_boundsDragBoundsn/aLimit drag region (%{min_x, max_x, min_y, max_y})
focusablebooleanfalseAdd to Tab order for keyboard navigation
cursoratom/stringn/aCursor style on hover (:pointer, :grab, etc.)
tooltipstringn/aTooltip text on hover
hit_rectHitRectn/aCustom hit testing region (%{x, y, w, h})

Visual feedback props

PropTypePurpose
hover_styleShapeStyleOverride fill, stroke, opacity on hover
pressed_styleShapeStyleOverride while pressed
focus_styleShapeStyleOverride when keyboard-focused
show_focus_ringbooleanShow/hide the default focus indicator
focus_ring_radiusnumberCorner 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
end

Without 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 typeData fields
:pressx, y, button, pointer, finger, modifiers
:releasex, y, button, pointer, finger, modifiers
:movex, y, pointer, finger, modifiers
:scrollx, 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)
end

Element-level events

Require interaction props on interactive groups:

Event typeTriggerData fields
:clickon_click: trueScoped under canvas ID (see below)
:enteron_hover: truen/a
:exiton_hover: truen/a
:dragdraggable: truex, y, delta_x, delta_y
:drag_enddraggable: truex, y
:key_pressfocusable: truekey, modifiers, text
:key_releasefocusable: truekey, modifiers
:focusedfocusable: truen/a
:blurredfocusable: truen/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
end

Other 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
end

Bar 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
end

Canvas 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)
  )
end

See also