# Create custom components and primitives

Choose the smallest extension mechanism that fits the job:

1. an ordinary function for composition;
2. `useCanvasContext()` for a simple custom painter;
3. `definePrimitive()` only for a platform component that needs style
   normalization, allocation-aware resolution, pure measurement, or custom
   layout capabilities.

## Ordinary composites

```tsx
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

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:

```tsx
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

`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.

```tsx
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

`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.

```tsx docs:complete-snippet=custom-primitive
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`](https://github.com/benlesh/pibbl/blob/main/packages/core/src/lib/define-primitive.test.tsx).

## 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](/guides/three-js/).

## Implementation guidance for agents

Read the [Authoring, signals, and lifecycle companion](/agents/topics/lifecycle/) for ownership, adaptation, failure modes, and verification. [Agent start](/agents/) provides the version-selection workflow.

## Documentation version

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.
