LivePanels is headless, but it is not empty.

It gives you runtime components, DOM attrs, browser hooks, structural CSS, and projection data. Your application supplies the visible UI: page layout, toolbar, item frames, node cards, drawing palette, inspectors, menus, and theme.

The practical rule is: use LivePanels helpers for behavior; use your own markup and CSS for presentation.

Choose A Toolkit

Toolkits are use presets for components and attrs:

ToolkitAdds
LivePanels.Toolkit.CanvasRuntime root, item frames, drag, resize, item controls, selection, layout, history, clipboard, viewport JS.
LivePanels.Toolkit.GraphCanvas plus graph ports, connector attrs, graph selection, fit helpers.
LivePanels.Toolkit.DrawingCanvas plus drawing mode, style, vector item, erase, and point helpers.

Use the preset after mounting the canvas runtime:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub

use LivePanels.Toolkit.Canvas

Use focused imports when you want direct control:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub

import LivePanels.Toolkit.Runtime
import LivePanels.Toolkit.Controls.Item

Focused imports keep the runtime unchanged while limiting which helpers enter the application module.

Render The Runtime Root

Most applications render one runtime component:

<.canvas_runtime {canvas_runtime_attrs(assigns, class: "canvas-root")}>
  <:item :let={context}>
    ...
  </:item>
</.canvas_runtime>

canvas_runtime_attrs/2 passes the canvas id, client id, viewport state, runtime hooks, mode, and protocol attrs the browser needs. Put application classes and data attrs in the overrides.

Use slots for layer-specific customization when needed:

<.canvas_runtime {canvas_runtime_attrs(assigns)}>
  <:world_layer>
    <svg class="custom-grid" aria-hidden="true"></svg>
  </:world_layer>

  <:item :let={context}>
    <.item_shell context={context} />
  </:item>

  <:world_overlay>
    <g class="presence-cursors" aria-hidden="true"></g>
  </:world_overlay>

  <:viewport_layer>
    <div class="canvas-hud">...</div>
  </:viewport_layer>
</.canvas_runtime>

:world_layer is camera-addressed and sits below LivePanels items. :world_overlay is camera-addressed and sits above LivePanels items. Use it for world-space overlays such as pointer presence. :viewport_layer is screen-space and does not pan or zoom with the camera.

Keep product UI in the application. Keep runtime protocol attrs on the elements that need behavior.

Build Item Shells

The :item slot receives LivePanels.Item.RenderContext; the canonical item is context.item:

<:item :let={context}>
  <article
    class={[
      "window",
      context.selected? && "is-selected",
      context.focused? && "is-focused"
    ]}
  >
    <header {drag_handle_attrs(class: "window-titlebar")}>
      <span>{context.item.label}</span>
      <button {remove_item_attrs(context)}>Close</button>
    </header>

    <main class="window-body">
      {context.content}
    </main>

    <.resize_handles :if={context.view.resizable?} />
  </article>
</:item>

Use context.view for runtime decisions:

<button :if={context.view.removable?} {remove_item_attrs(context)}>Close</button>
<.resize_handles :if={context.view.resizable?} />

Do not inspect private state or duplicate capability rules in the application.

Build Toolbars And Inspectors

Toolbars are ordinary application markup with LivePanels attrs:

<nav class="toolbar">
  <button {add_item_attrs(:note)}>Note</button>
  <button {layout_attrs(:grid)}>Layout</button>
  <button {copy_selection_attrs()}>Copy</button>
  <button {paste_selection_attrs()}>Paste</button>
  <button {undo_attrs()}>Undo</button>
  <button {redo_attrs()}>Redo</button>
</nav>

Inspectors can read current projection:

defp assign_inspector(socket) do
  selection = LivePanels.Canvas.Read.selection(socket)

  assign(socket,
    selected_count: LivePanels.Canvas.Selection.count(selection),
    history: LivePanels.Canvas.Read.history(socket),
    clipboard: LivePanels.Canvas.Read.clipboard(socket)
  )
end

Use application events for product commands, and LivePanels commands for canvas mutations.

Respond To Viewport Profiles

Canvas roots expose the active viewport profile and measured viewport metrics:

  • data-livepanels-viewport-profile;
  • --livepanels-screen-width;
  • --livepanels-screen-height;
  • --livepanels-usable-width;
  • --livepanels-usable-height;
  • --livepanels-viewport-zoom;
  • --livepanels-inset-left, --livepanels-inset-right, --livepanels-inset-top, and --livepanels-inset-bottom.

Use viewport on the canvas to name profile rules:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub,
  viewport: [
    profiles: [
      compact: [screen_width_lte: 720],
      zoomed: [zoom_gte: 1.5]
    ]
  ]

The item render context also includes the current viewport snapshot:

<:item :let={context}>
  <.item_shell
    item={context.item}
    viewport_profile={context.viewport.profile}
  >
    {context.content}
  </.item_shell>
</:item>

Use CSS and item markup for presentation changes. Use viewport_frames on viewport item types when the runtime should project a different screen-pinned frame for a viewport profile. Use canvas item commands when a world item should change canonical placement.

Customize Graph UI

Graph ports are application UI:

<button
  :for={port <- context.ports}
  class={"port port--#{port.direction}"}
  {graph_port_anchor_attrs(port)}
  {if port.direction == :output,
    do: begin_connector_attrs(context.item, port),
    else: complete_connector_attrs(context.item, port)}
>
  {port.label}
</button>

Connectors render as connector items in the runtime world SVG projection. Style the built-in connector classes and data attrs instead of rendering connector SVG from host code:

.app-canvas .livepanels-connector-path {
  stroke: currentColor;
  stroke-width: 3px;
}

.app-canvas
  [data-livepanels-connector-emphasis="active"]
  .livepanels-connector-path {
  color: var(--app-accent);
}

LivePanels owns connector state, endpoint rules, path geometry, labels, markers, hit targets, and stream replacement. Your application owns what a node, port, line, label, marker, or selected connector looks like.

Customize Drawing UI

Drawing controls are application UI:

<button phx-click={set_cursor_mode_js(@livepanels.canvas_id, :draw, tool: :arrow, style: @draw_style)}>
  Arrow
</button>

<button phx-click={set_cursor_mode_js(@livepanels.canvas_id, :erase)}>
  Erase
</button>

The browser owns the current draw mode and draft. The server owns committed vector items after the drawing command is accepted.

Use a LiveView assign only when your toolbar needs to display the selected mode, tool, or style.

Runtime CSS

The package CSS is structural. It provides runtime layer behavior, transforms, hit areas, resize affordances, and browser hook expectations.

Import it once:

import "livepanels/runtime.css";

or:

@import "livepanels/runtime.css";

Then add your own CSS for the app. Avoid overriding LivePanels structural selectors unless you are deliberately taking over that runtime surface.

Runtime-generated browser affordances expose public appearance hooks. Scope visual treatment under an app-owned root class:

.app-canvas .livepanels-drop-preview { ... }
.app-canvas .livepanels-marquee-rect { ... }
.app-canvas .livepanels-draw-draft-shape { ... }
.app-canvas .livepanels-draw-point-handle { ... }
.app-canvas .livepanels-item-frame.is-livepanels-snapping { ... }

Server-rendered helpers accept host classes for visuals that need extra elements or pseudo-elements:

<.resize_handles class="app-resize-handle" />

The runtime CSS keeps structure, hit targets, transforms, and measurement. Color, borders, shadows, palettes, and product vocabulary belong to the app.

Keyboard And Focus

Use keyboard scope attrs where canvas keyboard behavior should apply:

<main {keyboard_scope_attrs(class: "canvas-shell")}>
  ...
</main>

Use focus exemptions inside item frames:

<button {focus_exempt_attrs()} phx-click="open-inspector">Inspect</button>

This lets canvas focus and normal app controls coexist.

Use input_attrs/1 or the input attr on canvas_runtime/1 when a host needs to change wheel, pointer, or keyboard bindings:

<.canvas_runtime
  {canvas_runtime_attrs(assigns)}
  {input_attrs(drag_secondary: :pan, wheel_command: :y)}
/>

The same policy can be passed through the JS hook options with createLivePanelsCanvasHook({ input: ... }).

When Defaults Are Not Enough

Stay on toolkit helpers when you can.

Move to LivePanels.Toolkit.Integration.ProtocolAttrs or LivePanels.Toolkit.Integration.Render when you are building a manual render loop, custom SVG substrate, bridge package, or integration that cannot use canvas_runtime/1.

Manual integration APIs are public, but they are not the first path for an ordinary app canvas.