Skip to main content

Custom components

A plugin can add its own component to objects. Its interface appears in the inspector, next to Transform and Material.

Use this when your tool belongs to one object: notes on an object, a custom control, or a generator that stays with the thing it creates. A plugin like this does not need a panel.

Declare the component​

List it under components in the manifest. panels can be empty:

{
"id": "entity-notes",
"entry": "main.tsx",
"panels": [],
"components": [{ "id": "notes", "title": "Notes", "entry": "main.tsx" }]
}

Write the component​

import {
Button,
Column,
Text,
defineProps,
useEntity,
useSignals,
useSyncedState,
} from '@was/extension';
import { TransformComponent } from '@was/engine';

const useProps = defineProps({ step: { type: 'number', default: 0.5 } });

export default function Notes() {
useSignals(); // re-render when scene data read here changes
const props = useProps(); // values from the inspector form
const entity = useEntity(); // the object this is attached to
const [note, setNote] = useSyncedState('note', '');

return (
<Column gap={2}>
<Text muted>{note}</Text>
<Button
onClick={() =>
setNote(`y=${entity.getComponent(TransformComponent)?.get('position').y}`)
}
>
Remember
</Button>
</Column>
);
}

These hooks do the work:

HookGives you
useEntity()the object your component is attached to
useWorld()the scene that object is in
useProps()the values from the form you declared with defineProps
useSignals()a re-render when scene data you read during render changes
useSyncedState()state stored on the object and synced like any other component
useSelected()whether the object is selected in the editor
useViewportPointer()mouse events in the viewport, with the object and point under the pointer

defineProps uses the same field types as script properties: number, string, boolean, colour, select, entity, scene, space, resource, record, array and group. So your component gets a proper form in the inspector with no extra work.

Change the values from your component​

props.$set(patch) changes your component's own fields, the way the inspector does: the change syncs to everyone and can be undone. It is checked against defineProps: numbers stay within their min and max, and a field you did not declare throws an error. Use it to drag a value in the viewport:

<Handle axis={[0, 1, 0]} onDrag={(p) => props.$set({ height: p[1] })} />

Field names that start with $ are reserved for this, so defineProps refuses them.

Declare the state​

defineState declares the component's state with defaults, the same way defineProps declares the form:

const useScore = defineState({ points: { type: 'number', default: 0 } });

const [score, setScore] = useScore(); // inside the component

Scripts then read this state typed, with the same defaults.

Translate it​

i18n() or useEditorLocale() from @was/extension give you t(key, vars) over your plugin's locale files, in the editor's language.

How it runs​

Your component is real React 19. Hooks, useEffect, context and Suspense all work as usual.

The editor draws your component with its own elements, so it looks like part of the editor.

Two things follow from this:

  • You build only from the provided elements. Arbitrary HTML is not available. In the inspector: Column, Row, Text, Button, Divider, InputText, InputNumber, InputColor, Slider, Checkbox and Select. In the viewport: Overlay, Gizmo, Group, Box, Sphere, Line, Handle and Control. Give a Gizmo or an Overlay when="selected" to show it only on the selected object.
  • Write event handlers as usual. onClick works the normal way. The one thing you cannot do is pass a DOM event around.

How people add it​

Your component can be added in two ways:

Added fromResult
+ new component, in the Extensions groupit is added to the object that is already selected
The toolbar, under +, in the Plugins groupa new object is created with your component and selected

The Plugins heading in the toolbar shows people where an unfamiliar button came from.

A plugin folder connected from your computer also appears in that toolbar list if it has a panel, so you can open it without going through the Plugins panel each time. A folder with only an overlay does not appear, because there is nothing to open.

Use it in scripts and in the published project​

The component's interface works in the editor only. Its values are saved on the object, so the project can use them when it plays:

  • scripts read and change the component with ctx.plugin('<plugin id>/<component id>') → The ctx API;
  • your plugin's own code for the published project does the same → Runtime code.

Trust​

It starts without a click

A custom component starts whenever anyone opens a project that contains it. That is why it goes through the stricter check described in Trust and verification.


Next: Runtime code — make your plugin work in the published project.