A Phoenix LiveView component for rendering and drawing on an HTML canvas.
Setup
No JavaScript build step is required. The hook is colocated and injected at runtime automatically.
If you use colocated hooks elsewhere in your app, ensure your LiveSocket merges them:
import {hooks as colocatedHooks} from "phoenix-colocated/my_app"
const liveSocket = new LiveSocket("/live", Socket, {
hooks: {...colocatedHooks},
// ...
})Usage
Render a canvas element in your LiveView template:
<Easel.LiveView.canvas id="my-canvas" width={300} height={300} />Then draw to it from any event handler:
def handle_event("draw", _, socket) do
canvas =
Easel.new(300, 300)
|> Easel.set_fill_style("blue")
|> Easel.fill_rect(0, 0, 100, 100)
|> Easel.render()
{:noreply, Easel.LiveView.draw(socket, "my-canvas", canvas)}
endYou can also pass initial ops to draw on mount:
<Easel.LiveView.canvas id="my-canvas" width={300} height={300} ops={@canvas.ops} />Clearing
To clear the canvas before drawing:
{:noreply, Easel.LiveView.clear(socket, "my-canvas")}Or clear and draw in one step:
{:noreply, Easel.LiveView.draw(socket, "my-canvas", canvas, clear: true)}Events
Enable mouse and keyboard events by setting the corresponding attributes. Events are sent as LiveView events with the canvas id as a prefix:
<Easel.LiveView.canvas
id="my-canvas"
width={300}
height={300}
on_click
on_mouse_move
on_key_down
/>Then handle them in your LiveView:
def handle_event("my-canvas:click", %{"x" => x, "y" => y}, socket) do
# ...
{:noreply, socket}
end
def handle_event("my-canvas:mousemove", %{"x" => x, "y" => y}, socket) do
# ...
{:noreply, socket}
end
def handle_event("my-canvas:keydown", %{"key" => key}, socket) do
# ...
{:noreply, socket}
endAvailable event attributes: on_click, on_mouse_down, on_mouse_up,
on_mouse_move, on_key_down.
Layers
Use canvas_stack/1 to layer multiple canvases. Each layer is an
independent <canvas> element stacked via CSS. Only layers whose
assigns change get re-patched — static layers like backgrounds are
sent once:
<Easel.LiveView.canvas_stack id="game" width={800} height={600}>
<:layer id="background" ops={@background.ops} />
<:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} />
<:layer id="ui" ops={@ui.ops} />
</Easel.LiveView.canvas_stack>Templates and Instances
For scenes with many similar shapes, define a template once and stamp out instances. The template ops are cached client-side; only the per-instance data (position, rotation, color) is sent each frame:
canvas =
Easel.new(800, 600)
|> Easel.template(:boid, fn c ->
c
|> Easel.begin_path()
|> Easel.move_to(12, 0)
|> Easel.line_to(-4, -5)
|> Easel.line_to(-4, 5)
|> Easel.close_path()
|> Easel.fill()
end)
|> Easel.instances(:boid, instances)
|> Easel.render()Pass templates alongside ops in your template:
<Easel.LiveView.canvas id="c" ops={@canvas.ops} templates={@canvas.templates} ... />Animation
Start a server-side animation loop:
def mount(_params, _session, socket) do
socket =
socket
|> assign(:state, initial_state())
|> assign(:canvas, Easel.new(600, 400) |> Easel.render())
|> Easel.LiveView.animate("my-canvas", :state, fn state ->
new_state = tick(state)
canvas = render(new_state)
{canvas, new_state}
end, interval: 16, canvas_assign: :canvas)
{:ok, socket}
end
def handle_info({:easel_tick, id}, socket) do
{:noreply, Easel.LiveView.tick(socket, id)}
endThe canvas is redrawn each tick via LiveView's normal rendering cycle.
The hook uses requestAnimationFrame to sync draws with the browser's
refresh rate — multiple server updates between frames are coalesced.
Summary
Functions
Starts a server-side animation loop that redraws a canvas at a fixed interval.
Renders a <canvas> element wired to a colocated LiveView hook.
Renders a stack of layered canvases. Each layer is an independent
<canvas> element, stacked via CSS position: absolute. Only layers
whose ops change get redrawn — LiveView's normal diffing handles this.
Clears the entire canvas.
Pushes draw operations to a canvas element on the client.
Renders an export button that downloads the canvas as a PNG image.
Stops a running animation.
Processes an animation tick. Call this from your handle_info
Functions
Starts a server-side animation loop that redraws a canvas at a fixed interval.
The tick_fn receives the current state and must return {%Easel{}, new_state}.
Each frame, the rendered ops are stored in a canvas assign so the template
re-renders and the hook redraws automatically.
Returns the socket with the animation state stored in assigns.
Options
:interval- milliseconds between frames (default16, ~60fps):canvas_assign- assign key to store the rendered canvas (default: same asstate_key). Your template should bindops={@canvas_assign_key.ops}(or wherever you read ops from).
Example
In your LiveView mount/3:
def mount(_params, _session, socket) do
initial = %{balls: [...], canvas: Easel.new(600, 400)}
socket =
socket
|> assign(:state, initial)
|> Easel.LiveView.animate("my-canvas", :state, fn state ->
new_balls = tick(state.balls)
canvas = render_balls(new_balls)
{canvas, %{state | balls: new_balls, canvas: canvas}}
end)
{:ok, socket}
endYour LiveView must include a handle_info clause to receive ticks:
def handle_info({:easel_tick, id}, socket) do
{:noreply, Easel.LiveView.tick(socket, id)}
endTo stop the animation:
Easel.LiveView.stop_animation(socket, "my-canvas")
Renders a <canvas> element wired to a colocated LiveView hook.
Attributes
id(required) - unique DOM id, also used to target draw commandswidth- canvas width in pixelsheight- canvas height in pixelsops- list of ops to draw (default[]). When this changes, the hook automatically clears and redraws.class- CSS class for the canvas elementon_click- enable click events (pushes"#{id}:click")on_mouse_down- enable mousedown events (pushes"#{id}:mousedown")on_mouse_up- enable mouseup events (pushes"#{id}:mouseup")on_mouse_move- enable mousemove events (pushes"#{id}:mousemove")on_key_down- enable keydown events (pushes"#{id}:keydown")
Any additional attributes are passed through to the <canvas> element.
Attributes
id(:string) (required)width(:integer) - Defaults tonil.height(:integer) - Defaults tonil.ops(:list) - Defaults to[].templates(:map) - Defaults to%{}.class(:string) - Defaults tonil.on_click(:boolean) - Defaults tofalse.on_mouse_down(:boolean) - Defaults tofalse.on_mouse_up(:boolean) - Defaults tofalse.on_mouse_move(:boolean) - Defaults tofalse.on_key_down(:boolean) - Defaults tofalse.- Global attributes are accepted.
Renders a stack of layered canvases. Each layer is an independent
<canvas> element, stacked via CSS position: absolute. Only layers
whose ops change get redrawn — LiveView's normal diffing handles this.
Example
<Easel.LiveView.canvas_stack id="game" width={800} height={600}>
<:layer id="background" ops={@background.ops} />
<:layer id="sprites" ops={@sprites.ops} templates={@sprites.templates} />
<:layer id="ui" ops={@ui.ops} />
</Easel.LiveView.canvas_stack>Slots
Each :layer slot accepts:
id(required) — unique DOM id for this layer's canvasops— list of drawing operationstemplates— map of template definitions (for instance rendering)on_click,on_mouse_down,on_mouse_up,on_mouse_move,on_key_down— event flags
Only the topmost layer with event flags will receive pointer events.
Lower layers have pointer-events: none by default.
Attributes
id(:string) (required)width(:integer) (required)height(:integer) (required)class(:string) - Defaults tonil.- Global attributes are accepted.
Slots
layer(required) - Accepts attributes:id(:string) (required)ops(:list)templates(:map)on_click(:boolean)on_mouse_down(:boolean)on_mouse_up(:boolean)on_mouse_move(:boolean)on_key_down(:boolean)
Clears the entire canvas.
Pushes draw operations to a canvas element on the client.
This uses push_event to send ops directly to the hook without
going through the normal render cycle. Useful for one-off draws
from event handlers.
Options
:clear- iftrue, clears the canvas before drawing (defaultfalse)
Renders an export button that downloads the canvas as a PNG image.
When clicked, converts the target canvas to a PNG and triggers a browser file download.
Attributes
for(required) — the DOM id of the canvas to exportfilename— download filename (default"canvas.png")class— CSS class for the button
Any additional attributes are passed through to the <button> element.
Example
<Easel.LiveView.canvas id="my-canvas" width={300} height={300} ops={@ops} />
<Easel.LiveView.export_button for="my-canvas" filename="drawing.png">
Export PNG
</Easel.LiveView.export_button>Attributes
for(:string) (required)filename(:string) - Defaults to"canvas.png".class(:string) - Defaults tonil.- Global attributes are accepted.
Slots
inner_block(required)
Stops a running animation.
Processes an animation tick. Call this from your handle_info:
def handle_info({:easel_tick, id}, socket) do
{:noreply, Easel.LiveView.tick(socket, id)}
end