Custom renderers
Register custom Vue components for supported JSON Canvas node types while keeping board interactions intact.
Custom renderers make nodes look like your product, not the default text box. The board shell still handles positioning, selection, resize handles, and z-ordering — your renderer only draws content.
Choose the data shape first
Custom renderers do not create new node types or a second node schema. They read
the same BoardNode records as the default renderer.
| Need | Use |
|---|---|
| Editable text cards | type: 'text' with node.text. |
| File previews | type: 'file' with node.file and optional node.subpath. |
| Link previews | type: 'link' with node.url. |
| Visual containers | type: 'group' with node.label, node.background, and node.backgroundStyle. |
| Product state that is richer than JSON Canvas | Choose one canonical app model, then derive board nodes from it. |
Do not put the same product field in two places. If a workflow record owns status and assignee, keep that record as the source of truth and rebuild the matching board node fields from it. If the board document is the source of truth, keep the data in supported JSON Canvas fields.
For app-owned records, use the board node ID as the join key and pass the app record through a scoped slot:
<script setup lang="ts">
import { computed, ref } from 'vue'
import { asNodeId, createBoardEngine } from '@lupinum/board-core'
const tasks = ref([{ id: 'task-1', title: 'Qualify lead', status: 'active' }])
const tasksById = computed(
() => new Map(tasks.value.map((task) => [task.id, task])),
)
const engine = createBoardEngine()
engine.createNode({
id: asNodeId('task-1'),
type: 'text',
text: '',
})
</script>
<template>
<BoardRoot :engine="engine">
<template #node:text="{ node, selected }">
<TaskCard :task="tasksById.get(node.id)" :selected="selected" />
</template>
</BoardRoot>
</template>Here the task owns title and status; the board owns position, size,
selection, and camera state. If the task changes, the renderer updates from the
task record. If the user drags the card, the engine updates the board layout.
The renderer registry
Map supported node types to Vue components via the renderers prop. The first release supports the JSON Canvas node types: text, file, link, and group.
<script setup lang="ts">
import { createBoardEngine } from '@lupinum/board-core'
import type { BoardRendererRegistry } from '@lupinum/vue-board'
import TextCard from './TextCard.vue'
const engine = createBoardEngine()
const renderers: BoardRendererRegistry = {
text: TextCard,
}
</script>
<template>
<BoardRoot :engine="engine" :renderers="renderers" style="height: 100vh" />
</template>Text nodes without a renderer fall through to the built-in text renderer. Other supported node types fall back to a simple label unless you provide a renderer, slot, or fallbackRenderer.
Writing a custom renderer
A renderer is a standard Vue component that receives props from the board:
<!-- TextCard.vue -->
<script setup lang="ts">
import type { BoardNode } from '@lupinum/board-core'
defineProps<{
node: BoardNode
selected: boolean
editing: boolean
beginEdit: () => void
commitText: (text: string) => void
}>()
</script>
<template>
<div class="w-full h-full">
{{ node.text }}
</div>
</template>Renderer props
| Prop | Type | Purpose |
|---|---|---|
node | BoardNode | The node record: position, size, type, JSON Canvas fields, and metadata. |
selected | boolean | Whether the node is currently selected. |
editing | boolean | Whether this text node is in text editing mode. Always false for other node types. |
beginEdit | () => void | Enter text editing mode for a text node; otherwise a no-op. |
commitText | (text: string) => void | Commit a text node edit; otherwise a no-op. |
Your renderer fills the node's bounding box. Use width: 100% and height: 100%.
useBoardEngine() inside your renderer to access the engine and call commands.Editing from a renderer
The bundled editing callbacks are deliberately text-only. File, link, and group renderers should keep custom UI state locally and persist supported fields with engine.updateNode().
Do not mutate the node prop. Call engine commands or the renderer callbacks:
<script setup lang="ts">
import type { BoardNode } from '@lupinum/board-core'
const props = defineProps<{
node: BoardNode
editing: boolean
beginEdit: () => void
commitText: (text: string) => void
}>()
</script>
<template>
<button v-if="!editing" type="button" @click="beginEdit">Edit</button>
<textarea
v-else
:value="props.node.text"
data-editor="true"
@change="commitText(($event.target as HTMLTextAreaElement).value)"
/>
</template>contenteditable elements for keyboard editing, and mark custom editor controls with data-editor="true" so board pointer and double-click handlers pass through them.Slot-based rendering
You can also use named slots on BoardRoot instead of (or in addition to) the registry:
<BoardRoot :engine="engine">
<template #node:text="{ node, selected }">
<article :class="{ selected }">
{{ node.text }}
</article>
</template>
</BoardRoot>Resolution order
#node:{type}named slot (highest priority)#nodefallback slotrenderers[type]from the registryfallbackRendererprop- Built-in text renderer (lowest priority)
Level of detail (LOD)
BoardRoot assigns one of three LOD levels based on screen-space size:
| LOD | Condition | What renders |
|---|---|---|
full | Selected, or >= 96px | Your custom renderer. |
simple | 6–96px | Minimal placeholder with stripe pattern. |
hidden | < 6px | Nothing (unmounted from DOM). |
Selected nodes always render at full LOD regardless of size.
Implementation detail
Custom resize handles
Override resize handle appearance with the handle slot:
<BoardRoot :engine="engine">
<template #handle="{ handle }">
<div class="my-custom-handle" :data-resize="handle" />
</template>
</BoardRoot>The data-resize attribute is required — BoardRoot uses it to identify which handle was clicked.