This guide walks through building a complete custom component from scratch using the BYO SDK. The example component is a task list that displays tasks, lets Creators mark them complete or incomplete, dispatches events when tasks change, and updates the submission data.
Steps 1–4, 6, 9, and 11 are required for every component. Steps 5, 7, 8, and 10 are optional — useful for production-quality components but not required to get a working component into the Module Builder.
Step 1: Scaffold the Project
If you haven't installed the SDK yet, see Installation in the SDK Overview.
Once installed, scaffold a new project using the following command:
npx --package=./sdk.tgz create task-list --framework vanilla --ts
The command generates the following project structure:
task-list/
├── package.json
├── vite.config.ts
├── tsconfig.json
├── vitest.config.ts
├── src/
│ ├── entry.ts
│ └── TaskList/
│ ├── TaskList.ts
│ ├── TaskList.schema.ts
│ ├── TaskList.test.ts
│ ├── TaskList.mocks.ts
│ └── TaskList.css
Step 2: Define the Schema
Define your component's properties and events using the SDK schema utilities:
// src/TaskList/TaskList.schema.ts
import { z } from 'zod'
import { defineComponent, defineEvent } from '@unqork/byo-sdk/schema'
const TaskSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean().default(false),
})
const taskListSchema = z.object({
label: z.string().describe('Label').default('Tasks'),
items: z.array(TaskSchema).default([]),
allowAdd: z.boolean().describe('Allow adding tasks').default(true),
allowDelete: z.boolean().describe('Allow deleting tasks').default(true),
})
const taskToggledSchema = z.object({
name: z.string().default('taskToggled'),
payload: z.object({
task: TaskSchema,
items: z.array(TaskSchema),
}),
})
const taskAddedSchema = z.object({
name: z.string().default('taskAdded'),
payload: z.object({
task: TaskSchema,
items: z.array(TaskSchema),
}),
})
const taskDeletedSchema = z.object({
name: z.string().default('taskDeleted'),
payload: z.object({
task: TaskSchema,
items: z.array(TaskSchema),
}),
})
export const taskToggledDefinition = defineEvent({
name: 'Task Toggled',
type: 'taskToggled',
description: 'Fired when a task is toggled',
stability: 'STABLE',
schema: taskToggledSchema,
})
export const taskAddedDefinition = defineEvent({
name: 'Task Added',
type: 'taskAdded',
description: 'Fired when a task is added',
stability: 'STABLE',
schema: taskAddedSchema,
})
export const taskDeletedDefinition = defineEvent({
name: 'Task Deleted',
type: 'taskDeleted',
description: 'Fired when a task is deleted',
stability: 'STABLE',
schema: taskDeletedSchema,
})
export const taskListDefinition = defineComponent({
name: 'Task List',
type: 'taskList',
description: 'A task list component',
schema: taskListSchema,
events: [taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition],
})
export type Task = z.infer<typeof TaskSchema>
export type TaskListProps = z.infer<typeof taskListSchema>
export type TaskToggledEvent = z.infer<typeof taskToggledSchema>
export type TaskAddedEvent = z.infer<typeof taskAddedSchema>
export type TaskDeletedEvent = z.infer<typeof taskDeletedSchema>
The schema does the following:
- Defines component properties and their defaults using Zod.
- Defines custom events with typed payloads.
- Generates TypeScript types for your component.
- Drives manifest generation and the Module Builder UI.
Step 3: Implement the Component
Create a Web Component with Shadow DOM and typed event callbacks:
// src/TaskList/TaskList.ts
import styles from './TaskList.css?raw'
import {
type Task,
type TaskListProps,
type TaskToggledEvent,
type TaskAddedEvent,
type TaskDeletedEvent,
} from './TaskList.schema.js'
export class TaskList extends HTMLElement {
private _label: TaskListProps['label'] = 'Tasks'
private _items: Task[] = []
private _allowAdd: TaskListProps['allowAdd'] = true
private _allowDelete: TaskListProps['allowDelete'] = true
// Props set by the platform
set label(value: TaskListProps['label']) {
this._label = value
this._render()
}
get label(): TaskListProps['label'] {
return this._label
}
set items(value: Task[]) {
this._items = value
this._render()
}
set allowAdd(value: TaskListProps['allowAdd']) {
this._allowAdd = value
this._render()
}
set allowDelete(value: TaskListProps['allowDelete']) {
this._allowDelete = value
this._render()
}
set submissionData(value: unknown) {
if (Array.isArray(value)) {
this._items = value as Task[]
this._render()
}
}
// Callbacks set by the platform
onTaskToggled: ((event: TaskToggledEvent) => void) | null = null
onTaskAdded: ((event: TaskAddedEvent) => void) | null = null
onTaskDeleted: ((event: TaskDeletedEvent) => void) | null = null
onSubmissionUpdate: ((value: unknown) => void) | null = null
connectedCallback(): void {
this.attachShadow({ mode: 'open' })
// Apply styles once via adoptedStyleSheets
const sheet = new CSSStyleSheet()
sheet.replaceSync(styles)
this.shadowRoot!.adoptedStyleSheets = [sheet]
this._render()
}
private handleToggle(id: string) {
const updated = this._items.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item
)
const task = updated.find((item) => item.id === id)!
this._items = updated
this._render()
// Fire event
this.onTaskToggled?.({
name: 'taskToggled',
payload: { task, items: updated },
})
this.onSubmissionUpdate?.(updated)
}
private handleAdd() {
const newTask: Task = {
id: crypto.randomUUID(),
title: 'New task',
completed: false,
}
const updated = [...this._items, newTask]
this._items = updated
this._render()
this.onTaskAdded?.({
name: 'taskAdded',
payload: { task: newTask, items: updated },
})
this.onSubmissionUpdate?.(updated)
}
private handleDelete(id: string) {
const task = this._items.find((item) => item.id === id)!
const updated = this._items.filter((item) => item.id !== id)
this._items = updated
this._render()
this.onTaskDeleted?.({
name: 'taskDeleted',
payload: { task, items: updated },
})
this.onSubmissionUpdate?.(updated)
}
private _render(): void {
if (!this.shadowRoot) return
this.shadowRoot.innerHTML = `
<div class="task-list-root">
<h3>${this._label}</h3>
<ul class="tasks">
${this._items
.map(
(item) => `
<li class="task ${item.completed ? 'completed' : ''}">
<input
type="checkbox"
${item.completed ? 'checked' : ''}
data-id="${item.id}"
/>
<span>${item.title}</span>
${
this._allowDelete
? `<button class="delete" data-id="${item.id}">Delete</button>`
: ''
}
</li>
`
)
.join('')}
</ul>
${this._allowAdd ? '<button class="add">Add Task</button>' : ''}
</div>
`
this._bindEvents()
}
private _bindEvents(): void {
this.shadowRoot?.querySelectorAll('input[type="checkbox"]').forEach((checkbox) => {
checkbox.addEventListener('change', (e) => {
const id = (e.target as HTMLInputElement).dataset.id!
this.handleToggle(id)
})
})
this.shadowRoot?.querySelectorAll('.delete').forEach((button) => {
button.addEventListener('click', (e) => {
const id = (e.target as HTMLButtonElement).dataset.id!
this.handleDelete(id)
})
})
this.shadowRoot?.querySelector('.add')?.addEventListener('click', () => {
this.handleAdd()
})
}
}
The component uses the following key patterns:
- Shadow DOM: Encapsulates styles and prevents CSS leakage.
- Typed callbacks: Ensure type safety for event payloads.
- Event objects: Include
nameandpayloadfields matching the event schema. - Property setters: Trigger re-renders.
- adoptedStyleSheets: Applies CSS once without re-injecting on each render.
Step 4: Export the Component
Register the component in the bundle entry point:
// ---------------------------------------------------------------------------
// BYOC Entry — vanilla web component (TypeScript)
//
// Runtime exports: { view, model } — consumed by the Unqork platform.
// Schema re-exports: { taskListDefinition, taskToggledDefinition, ... } — consumed by
// byoc build's manifest generator to produce manifest.json automatically.
// ---------------------------------------------------------------------------
// src/entry.ts
import { TaskList as TaskListView } from './TaskList/TaskList.js'
import {
taskListDefinition,
taskToggledDefinition,
taskAddedDefinition,
taskDeletedDefinition,
} from './TaskList/TaskList.schema.js'
// ---------------------------------------------------------------------------
// Component: taskList
// ---------------------------------------------------------------------------
export const taskList = {
view: async () => TaskListView as CustomElementConstructor,
model: async () => taskListDefinition.ModelClass,
}
// ---------------------------------------------------------------------------
// Schema re-exports — manifest generator traces _kind discriminators here
// ---------------------------------------------------------------------------
export { taskListDefinition, taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition }
The platform expects the following exports:
view— returns the Web Component constructor.model— returns the generatedModelClassfromdefineComponent.- The schema exports enable automatic manifest generation.
Step 5: Create Mock Scenarios (Optional)
Define test scenarios for the dev harness:
// src/TaskList/TaskList.mocks.ts
import { defineMockScenario } from '@unqork/byo-sdk/testing'
export const defaultScenario = defineMockScenario({
name: 'Default',
description: 'Empty task list',
props: {
label: 'My Tasks',
items: [],
},
})
export const withTasksScenario = defineMockScenario({
name: 'With Tasks',
description: 'Task list with some items',
props: {
label: 'Project Tasks',
items: [
{ id: '1', title: 'Design mockups', completed: true },
{ id: '2', title: 'Implement component', completed: false },
{ id: '3', title: 'Write tests', completed: false },
],
},
})
export const readOnlyScenario = defineMockScenario({
name: 'Read Only',
description: 'Task list with add/delete disabled',
props: {
label: 'Completed Tasks',
allowAdd: false,
allowDelete: false,
items: [
{ id: '1', title: 'Design mockups', completed: true },
{ id: '2', title: 'Implement component', completed: true },
],
},
})
Step 6: Test in the Dev Harness
Start the dev server:
npm run dev
Open http://localhost:5173. The dev harness includes the following features:
- Shows your component.
- Provides a scenario drop-down.
- Logs callback invocations to the console.
- Updates in real time as you edit code.
Test the following interactions:
- Switching between scenarios.
- Toggling tasks and checking the console for
onTaskToggledcalls. - Adding and deleting tasks.
- Modifying properties in
TaskList.mocks.ts.
Step 7: Write Tests (Optional)
Create unit tests using Vitest:
// src/TaskList/TaskList.test.ts
import { describe, it, expect, beforeAll } from 'vitest'
import * as exports from '../entry.js'
import type { Task } from './TaskList.schema.js'
describe('TaskList', () => {
let element: HTMLElement
beforeAll(async () => {
const ViewCtor = await exports.taskList.view()
const tagName = 'test-task-list'
if (!customElements.get(tagName)) {
customElements.define(tagName, ViewCtor)
}
element = document.createElement(tagName)
document.body.appendChild(element)
})
it('renders with default label', () => {
expect(element.shadowRoot?.innerHTML).toContain('Tasks')
})
it('updates when label prop changes', () => {
;(element as any).label = 'My Tasks'
expect(element.shadowRoot?.innerHTML).toContain('My Tasks')
})
it('renders items', () => {
const items: Task[] = [
{ id: '1', title: 'Task 1', completed: false },
{ id: '2', title: 'Task 2', completed: true },
]
;(element as any).items = items
const taskElements = element.shadowRoot?.querySelectorAll('.task')
expect(taskElements).toHaveLength(2)
expect(taskElements?.[0].textContent).toContain('Task 1')
expect(taskElements?.[1].classList.contains('completed')).toBe(true)
})
it('fires onTaskToggled when checkbox clicked', () => {
const items: Task[] = [{ id: '1', title: 'Task 1', completed: false }]
;(element as any).items = items
let eventFired = false
;(element as any).onTaskToggled = (event: any) => {
eventFired = true
expect(event.payload.task.completed).toBe(true)
}
const checkbox = element.shadowRoot?.querySelector('input[type="checkbox"]') as HTMLInputElement
checkbox.click()
expect(eventFired).toBe(true)
})
it('hides add button when allowAdd is false', () => {
;(element as any).allowAdd = false
expect(element.shadowRoot?.querySelector('.add')).toBeNull()
})
})
To run all tests, use the following command:
npm test
Step 8: Add Styling (Optional)
Create the stylesheet that was imported in Step 3:
/* src/TaskList/TaskList.css */
.task-list-root {
font-family: system-ui, sans-serif;
padding: 1rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.task-list-root h3 {
margin: 0 0 1rem 0;
font-size: 1.25rem;
}
.tasks {
list-style: none;
padding: 0;
margin: 0 0 1rem 0;
}
.task {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
}
.task.completed span {
text-decoration: line-through;
color: #999;
}
.task input[type="checkbox"] {
cursor: pointer;
}
.task span {
flex: 1;
}
.task .delete {
padding: 0.25rem 0.5rem;
background: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.task .delete:hover {
background: #d32f2f;
}
.add {
padding: 0.5rem 1rem;
background: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.add:hover {
background: #1976d2;
}
The CSS is imported as ?raw and applied through Shadow DOM's adoptedStyleSheets API, as shown in Step 3. This approach does the following:
- Encapsulates styles in the component.
- Prevents CSS leakage to the rest of the page.
- Applies styles once without re-injecting on each render.
Step 9: Build and Validate
Build for production:
npm run build:prod
The command runs the following steps:
- Bumps version to
1.0.1. - Bundles code with Vite.
- Generates
dist/manifest.json. - Creates
dist/task-list.tar.gz.
Note: Use
npm run buildfor development builds without version bumping.
Step 10: Preview the Production Build (Optional)
Run the following command to open the dev harness serving the production bundle from dist/:
npm run preview
Before uploading, verify the following:
- All functionality works as expected.
- No console errors are present.
- File size is acceptable.
Step 11: Use in the Module Builder
After uploading task-list.tar.gz to Unqork:
-
Add the component to your module:
- Open the Module Builder.
- Locate your custom component in the component tray under the Custom or BYO section.
- Drag and drop the Task List component onto the canvas.
-
Configure the component settings: Use the Settings Panel to configure your component's properties:
- Label: Set to "Project Tasks".
- Items: Bind to a data source using
={{components.dataSource.value}}. - Allow Add: Toggle to enable or disable adding tasks.
- Allow Delete: Toggle to enable or disable deleting tasks.
-
Configure triggers: In the Settings Panel, configure what happens when events fire:
- onTaskToggled: Set up a trigger that executes when a task is toggled, like updating task status in a database.
- onTaskAdded: Set up a trigger that executes when a new task is added, like saving the new task.
- For each trigger, specify the context field to store event data and the target component to execute.
Advanced Patterns
Lazy Loading Large Dependencies
Defer loading heavy libraries until needed:
export class TaskList extends HTMLElement {
private chartLib: any = null
async loadChartLibrary() {
if (!this.chartLib) {
this.chartLib = await import('./chart-library.js')
}
return this.chartLib
}
async showChart() {
const lib = await this.loadChartLibrary()
lib.render(this.querySelector('.chart-container'))
}
}
Multiple Components in One Asset
To bundle multiple components in a single asset, export them all from the same entry.ts file:
// src/entry.ts
import { TaskList as TaskListView } from './TaskList/TaskList.js'
import { taskListDefinition, taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition } from './TaskList/TaskList.schema.js'
import { TaskCard as TaskCardView } from './TaskCard/TaskCard.js'
import { taskCardDefinition, taskCardClickedDefinition } from './TaskCard/TaskCard.schema.js'
// ---------------------------------------------------------------------------
// Component: taskList
// ---------------------------------------------------------------------------
export const taskList = {
view: async () => TaskListView as CustomElementConstructor,
model: async () => taskListDefinition.ModelClass,
}
// ---------------------------------------------------------------------------
// Component: taskCard
// ---------------------------------------------------------------------------
export const taskCard = {
view: async () => TaskCardView as CustomElementConstructor,
model: async () => taskCardDefinition.ModelClass,
}
// ---------------------------------------------------------------------------
// Schema re-exports — manifest generator traces _kind discriminators here
// ---------------------------------------------------------------------------
export {
taskListDefinition,
taskToggledDefinition,
taskAddedDefinition,
taskDeletedDefinition,
taskCardDefinition,
taskCardClickedDefinition,
}
Each component in the asset is independently configurable in the Module Builder.
Commands for Imperative Actions
Commands let the platform trigger imperative actions on your component. Events flow from your component to the platform; commands flow from the platform to your component.
Define a command:
// src/TaskList/TaskList.schema.ts
import { defineCommand } from '@unqork/byo-sdk/schema'
const resetSchema = z.object({
clearCompleted: z.boolean().optional().default(false),
})
export const resetDefinition = defineCommand({
name: 'Reset Tasks',
type: 'resetTasks',
description: 'Reset task list to initial state',
schema: resetSchema,
})
export const taskListDefinition = defineComponent({
name: 'Task List',
type: 'taskList',
schema: taskListSchema,
events: [taskToggledDefinition, taskAddedDefinition, taskDeletedDefinition],
commands: [resetDefinition], // ← Add commands array
})
Listen for commands in your component:
// src/TaskList/TaskList.ts
export class TaskList extends HTMLElement {
connectedCallback() {
this.addEventListener('resetTasks', (e: CustomEvent) => {
const { clearCompleted = false } = e.detail || {}
if (clearCompleted) {
this._items = this._items.filter(task => !task.completed)
} else {
this._items = []
}
this.onSubmissionUpdate?.(this._items)
this._render()
})
}
}
Trigger from the Module Builder:
Configure a BYOC Execute output on a logic component like an Initializer, Decisions, or Logic Block:
{
"outputs": [
{
"targetId": "taskList1",
"type": "byocExecute",
"value": {
"componentType": "taskList",
"commandType": "resetTasks",
"args": { "clearCompleted": true }
}
}
]
}
When to use commands:
- Imperative operations: Reset, refresh, focus, validate, and so on.
- Headless or service components: Payment gateways, authentication, analytics, and so on.
- Third-party libraries with method-based APIs: D3, Stripe, Leaflet, and so on.
Differences from events:
- Direction: Commands → component; events → platform.
- Pattern: fire-and-forget with no return value; events trigger workflows.
- Use case: Imperative actions; events represent state changes.
Next Steps
- CLI Reference — complete command documentation.
- Testing Guide — advanced testing patterns.
- Migration Guide — migrate from Vega BYO.
Complete Source Code
The complete task list component source is available in the SDK examples:
npx --package=./sdk.tgz create task-list-example --template examples/task-list