Create custom components and primitives
Choose the smallest extension mechanism that fits the job:
- an ordinary function for composition;
useCanvasContext()for a simple custom painter;definePrimitive()only for a platform component that needs style normalization, allocation-aware resolution, pure measurement, or custom layout capabilities.
Ordinary composites
Section titled “Ordinary composites”import { Group, Rectangle, Text, type PibblNode } from "@pibbl/core";
interface BadgeProps { label: string; active: boolean;}
function Badge({ label, active }: BadgeProps): PibblNode { return ( <Group> <Rectangle style={{ width: 180, height: 64, fill: active ? "gold" : "navy" }} /> <Text style={{ left: 16, top: 18, fill: active ? "black" : "white" }}> {label} </Text> </Group> );}The function is the component identity. Call it through JSX or
createElement; a direct JavaScript call does not create a Pibbl element or
establish render context.
Placed style props
Section titled “Placed style props”When a layout parent places a custom component, its style prop retains the
author’s public fields and, for a whole-style signal, the original signal
identity. Pibbl may use a readonly shallow copy for an object style to carry
private placement provenance; it never mutates the caller object, but object
identity is not guaranteed for this positioned path. Use field values rather
than reference equality when a component receives placement style.
If the component forwards that style with { ...style }, matching resolved
left and top remain parent placement and are consumed once. Different
values become local offsets. A new style object that does not spread the prop
is independent local geometry even when its offsets happen to match the
parent’s.
To let the receiving custom component own a reactive input, declare
SignalValue<T> and resolve it at that boundary:
import { resolveSignalValue, type SignalValue } from "@pibbl/core";
interface SparklineDataProps { data: SignalValue<readonly number[]>;}
function SparklineData({ data }: SparklineDataProps): PibblNode { const values = resolveSignalValue(data); return <Sparkline points={values} />;}Resolve only fields your API declares as signal-valued. Do not recursively walk
datasets, arbitrary descriptors, callbacks, or metadata looking for signals.
Callbacks are values and are never invoked by resolution. If the API requires a
signal object for later imperative use, type that prop as Signal<T> or
WritableSignal<T> rather than SignalValue<T>.
Direct Canvas painting
Section titled “Direct Canvas painting”useCanvasContext() returns the current isolated Canvas 2D context during a
live render. It does not allocate a hook slot and does not schedule work. It is
unavailable outside rendering and during pure measurement.
import { useCanvasContext, useLayoutBox, type BoxStyle, type StrokeStyle,} from "@pibbl/core";
interface SparklineProps { points: readonly number[]; style: BoxStyle & { stroke: StrokeStyle; strokeWidth?: number };}
function Sparkline({ points, style }: SparklineProps): void { const context = useCanvasContext(); const box = useLayoutBox(); if (points.length === 0) return;
context.beginPath(); points.forEach((point, index) => { const x = points.length === 1 ? 0 : (index / (points.length - 1)) * box.width; const y = box.height - point * box.height; if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.strokeStyle = style.stroke; context.lineWidth = style.strokeWidth ?? 1; context.stroke();}Canvas save/restore, source order, clipping, layout placement, failure cleanup, and parent transforms apply as they do for built-ins. A plain painter has no pure intrinsic measurement capability.
An ordinary component does not receive framework style automatically. To
filter one painter, return its paint from a primitive carrying style.filter;
to filter several painters or built-ins as one silhouette, put them beneath a
filtered Group.
A complete platform primitive
Section titled “A complete platform primitive”definePrimitive separates program props from specified style, installs
private capabilities on the returned component value, resolves style before
render, and permits pure measurement without invoking the renderer.
import { definePrimitive, type Percentage, type SystemStyle,} from "@pibbl/core";
type GaugeFill = string | CanvasGradient | CanvasPattern;
interface GaugeProps { value: number;}
interface GaugeStyle extends SystemStyle { width: number | Percentage; height?: number; fill?: GaugeFill;}
interface NormalizedGaugeStyle extends SystemStyle { width: number | Percentage; height: number; fill: GaugeFill;}
interface ResolvedGaugeStyle extends SystemStyle { width: number; height: number; fill: GaugeFill;}
export const Gauge = definePrimitive< GaugeProps, GaugeStyle, NormalizedGaugeStyle, ResolvedGaugeStyle>( function renderGauge({ value }, style, context) { const progress = Math.max(0, Math.min(1, value)); context.fillStyle = style.fill; context.fillRect(0, 0, style.width * progress, style.height); }, { normalizeStyle: (style) => ({ ...style, height: style.height ?? 12, fill: style.fill ?? "#2563eb", }), resolveStyle: (style, resolution) => ({ ...style, width: typeof style.width === "number" ? style.width : (Number.parseFloat(style.width) / 100) * resolution.percentageBasis.width, }), measure: ({ style, constraints }) => { if ( typeof style.width !== "number" && !Number.isFinite(constraints.maxWidth) ) { return { status: "unsupported", reason: "percentage width needs a finite maximum width", }; } const percentageBasis = Number.isFinite(constraints.maxWidth) ? constraints.maxWidth : 0; const width = typeof style.width === "number" ? style.width : (Number.parseFloat(style.width) / 100) * percentageBasis; return { status: "measured", size: { width, height: style.height } }; }, },);The four generic stages are program props, specified style, normalized style,
and resolved style. Program props cannot declare reserved key or style.
The public primitive input type maps declared program and style leaves to
signal-valued inputs. Render, normalization, and measurement callbacks receive
resolved values, not signal wrappers. Pure standalone measurement resolves
untracked and never creates a persistent graph consumer.
When a specified style admits percentages or auto, a resolver is required,
and the resolved type cannot retain allocation syntax. The render callback
receives only program props, the finite resolved style, and the isolated Canvas
context.
normalizeStyle must be pure and allocation-independent. resolveStyle
receives the current allocation, percentage basis, constraints, component
name, and render algorithm. measure receives program props, normalized style,
constraints, a Canvas text-measurement service, and a recursive
measureElement function. It must not paint, invoke hooks, load images, register
events, or acquire resources. Return { status: "unsupported", reason } when
intrinsic sizing cannot be answered safely.
Because each declared style extends SystemStyle, Gauge also accepts one
PibblFilter or a readonly filter list. The filter is owned by render dispatch:
Pibbl removes it before calling custom normalizeStyle and resolveStyle, then
attaches its validated canonical readonly list to the final resolved style seen
by the render callback. Custom capabilities cannot discard or reinterpret the
filter. Pure measure ignores it and never checks browser filter support or
allocates a surface.
With a nonempty filter, the render callback executes once against the real
upright local OffscreenCanvasRenderingContext2D. The callback’s
context.canvas and context.getTransform() describe that local target; Pibbl
applies parent placement or a receiving Group transform exactly once when the
completed filtered result is composited. Imperative transforms, clips, alpha,
or native Canvas filter/shadow calls made by the callback remain paint inside
the custom primitive’s captured source. Pibbl does not reinterpret them as
declarative presentation metadata.
The mutable Canvas current path cannot be copied between contexts. Do not rely
on beginning an implicit path outside a filter/Layer boundary and continuing it
inside, or the reverse. Keep custom draw operations self-contained with
beginPath() or use Path2D. The built-ins and examples follow this boundary.
Nonempty filters require browser OffscreenCanvas plus native Canvas filter;
[] is a no-op and has no offscreen requirement.
Ordinary application components should rarely need definePrimitive. The
executable contract is
define-primitive.test.tsx.
Custom 3D shapes
Section titled “Custom 3D shapes”Use ordinary Three geometry, materials, meshes, shaders, loaders, and addons
inside a component returned by defineThreeLayer(). Pibbl deliberately does not
wrap those APIs in a second primitive vocabulary. The application owns and
disposes its Three resources; Pibbl owns the surrounding render-layer lifecycle,
composition, event arbitration, and focus.
See Use Three.js inside Pibbl.
Implementation guidance for agents
Section titled “Implementation guidance for agents”Read the Authoring, signals, and lifecycle companion for ownership, adaptation, failure modes, and verification. Agent start provides the version-selection workflow.
Documentation version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision c321a83. ALPHA — NOT FOR PRODUCTION USE.