Custom Native Components
Register your own native component type that the runtime serializes and your
own resource pack's JSON UI decodes and renders — extending the framework
beyond the built-in Panel / Text / Button / Image primitives.
This is a low-level extension point, not everyday API. To use it you need to understand two things:
- Bedrock JSON UI — control trees, bindings, and
modificationsto splice your control into an existing screen. - This runtime's serialization protocol — how a component's props become the compact binary payload your JSON UI control has to decode field-by-field.
Most UIs never need this: compose the built-in components instead. Reach for a custom native component only when you need a JSON UI capability the built-ins don't expose (a custom renderer, a special control, a binding the runtime doesn't emit). Read the runtime source before you start: https://github.com/bedrock-core/ui/tree/main/packages/ui-runtime
The big picture
A native component spans two packages you control:
TypeScript (this runtime) Resource pack (your JSON UI)
───────────────────────── ────────────────────────────
1. component → { type, props }
2. Writer → form.button()/label()
3. registerComponent(type, …)
│
▼ serialized payload (fixed-width binary, one form button/label entry)
4. a JSON UI control decodes the
payload, gated on #type == 'type',
spliced into button_router / label_router
via modifications
The runtime renders everything through just two ActionForm primitives:
form.button() (routed by button_router, interactive) and form.label()
(routed by label_router, static). A custom component picks one of those slots
in its writer, and the matching router on the RP side decodes it.
TypeScript side
1. Define the component
A native component is a function component that returns a host element whose
type is your unique, namespaced string. Props must be serializable
primitives (string / number / boolean); use withControl to carry the
standard layout / visibility / enabled / background props.
import { withControl, type JSX } from '@bedrock-core/ui';
interface RatingProps {
stars: number;
background?: string;
}
function Rating({ stars, ...rest }: RatingProps): JSX.Element {
return {
type: 'mypack:rating',
props: {
...withControl(rest),
stars, // serialized and decoded by your RP control
},
};
}
2. Write the writer
A Writer emits the serialized payload into one slot. Use the provided slot
helpers — they keep your component consistent with the presenter's selection
mapping:
emitButton(payload, form, ctx, callbacks, icon?)— interactive (button) slot. Registerscallbacks.onPressagainst the current button index and advances it.emitLabel(payload, form)— static (label) slot.
import { emitLabel, type Writer } from '@bedrock-core/ui';
const ratingWriter: Writer = (payload, form, ctx, callbacks, props) => {
emitLabel(payload, form); // static → label_router
};
For an interactive control, call emitButton instead and pass ctx / callbacks
straight through so onPress is wired up. The optional 5th props argument lets
the writer read serialized values (e.g. to pass an icon path to form.button).
3. Register it
Register the type → behavior mapping once, before render() runs your
component. The built-ins register themselves lazily inside render(); put your
own registration at module load (an import side-effect-free top-level call, or a
setup function you call before your first render).
import { registerComponent } from '@bedrock-core/ui';
registerComponent('mypack:rating', { writer: ratingWriter });
registerComponent throws on a duplicate type, so clashes between addons
surface immediately instead of silently overriding — always namespace your type
(mypack:rating, not rating). A wrapper component that emits nothing itself and
just renders its children registers as { transparent: true } instead of a
writer (this is how fragment works).
Resource pack side
The serialized payload arrives as a single form button or label entry. On the
RP side you:
- Build a JSON UI control that decodes the payload. The runtime prefixes a
protocol header, then writes each prop as a fixed-width field (type-prefixed
s:/n:/b:, padded, with a per-field marker). Your control slices those fields out with%.Nsview bindings — exactly like the built-in components do. - Gate it on
#typeso it only renders for your component:(#pre_visible and (#type = 'mypack:rating')). - Splice it into the router — every form entry passes through
button_router(interactive,emitButton) orlabel_router(static,emitLabel); each control inside renders for every entry but stays hidden unless its#typegate matches. Add your control to that router'scontrolsarray with a JSON UImodificationsblock — the sameinsert_backpattern the runtime uses to inject controls into the vanillaserver_form:
// your_pack/ui/button_router_ext.json
{
// Modify the runtime's router (same namespace + element name = merge).
// Your pack must sit ABOVE @bedrock-core's RP in the pack stack.
"namespace": "core_ui_common",
"button_router": {
"modifications": [
{
"array_name": "controls",
"operation": "insert_back",
"value": [
{ "rating@mypack.rating": {} }
]
}
]
}
}
For a static (emitLabel) component, modify label_router the same way. The
inserted control (mypack.rating) is the decode panel from steps 1–2 — its
#type gate is what keeps it hidden for every form entry that isn't yours.
The built-in controls are the reference implementation for the decode bindings and the router wiring — read them alongside the serializer:
- Decode +
#typegate pattern:packages/resource-pack/packs/RP/ui/core-ui/components/*.json - Router shape:
core-ui/common/button_router.json,core-ui/common/label_router.json - Field layout & protocol
VERSION:packages/ui-runtime/src/core/serializer.ts
The payload format is versioned (PROTOCOL_HEADER / VERSION in serializer.ts).
If the runtime's protocol version changes, your decode bindings must be updated to
match — a mismatched header means your control reads garbage.
API reference
import {
registerComponent,
getRegisteredTypes,
emitButton,
emitLabel,
type ComponentDescriptor,
type Writer,
} from '@bedrock-core/ui';
registerComponent(type, descriptor)
type: string— unique, namespaced component type; must match the JSON UI control's#typegate.descriptor: ComponentDescriptor—{ writer }for a renderable control, or{ transparent: true }for a children-only wrapper.- Throws
SerializationErroriftypeis already registered, or if the descriptor is neither transparent nor has a writer.
getRegisteredTypes(): string[]
All currently registered type strings, sorted. Handy for debugging "unknown component type" errors.
ComponentDescriptor
interface ComponentDescriptor {
writer?: Writer; // emits the payload via emitButton / emitLabel
transparent?: boolean; // emits nothing; the serializer walks to children
}
Writer
type Writer = (
payload: string, // serialized props
form: ActionFormData, // target form
ctx: SerializationContext | undefined, // button-index / callback map
callbacks: Record<string, (...args: unknown[]) => void>, // function props (onPress, …)
props?: SerializableProps, // serialized values, if needed
) => void;
emitButton / emitLabel
Slot helpers for writers — see step 2.
Caveats
- Namespace your type. Registration throws on duplicates;
mypack:ratingavoids cross-addon clashes. - Register before serialization. If a component serializes before its type is
registered,
render()throws "Unknown native component type". - Props must be serializable primitives (
string/number/boolean). Functions are collected as callbacks (e.g.onPress); everything else throws. - Field budget. Each string/number field is fixed-width (≤ 80 bytes per value), and an element can carry at most 64 props — keep payloads lean.
- You maintain the RP side. A runtime protocol bump requires updating your decode bindings to match.