# Authoring a custom element

Define a props interface with SignalValue, extend Node, wire @initial and @signal, add children in the constructor, then implement animateIn and animate.

A custom element is a class. It extends `Node` or a closer built-in. It takes a props interface. It builds a child tree in the constructor. It exposes generator methods for animation.

Start from a catalog template when one already covers the design. This page is the from-scratch path.

## Props

Every custom prop is wrapped in `SignalValue`. Extend `NodeProps` (or `LayoutProps` / `Txt` props) so position, opacity, and scale stay available.

```tsx
import { Node, NodeProps } from '@vidova/2d';
import { SignalValue, PossibleColor } from '@vidova/core';

export interface HelloTitleProps extends NodeProps {
  label?: SignalValue<string>;
  textColor?: SignalValue<PossibleColor>;
  textSize?: SignalValue<number>;
}
```

Use `PossibleColor` for color props so callers can pass a hex string. Extend `Layout` and `LayoutProps` when the element is itself a flex container.

## Class

The class must extend `Node` or one of its subclasses. Pick the closest built-in. A title that is only text can extend `Txt`. A chip with a background extends `Node` and adds a `Rect` plus a `Txt`.

```tsx
export class HelloTitle extends Node {
  // implementation
}
```

The exported class name is `componentName` on the [component](/docs/mcp/tools/component) `create` call.

## Signals

Each prop on the interface needs a class field of the same name. Decorate it with `@initial` and `@signal`. Colors use `@colorSignal` and `ColorSignal`.

```tsx
export class HelloTitle extends Node {
  @initial('Hello')
  @signal()
  public declare readonly label: SimpleSignal<string, this>;

  @initial('#ffffff')
  @colorSignal()
  public declare readonly textColor: ColorSignal<this>;

  @initial(48)
  @signal()
  public declare readonly textSize: SimpleSignal<number, this>;
}
```

Fields use `public`, `declare`, and `readonly`. `@signal` is required for every prop you accept. `@initial` sets the value when the caller omits it.

Do not name a field after a `Node` member. `draw`, `size`, `scale`, `opacity`, `position`, and the rest of that list overwrite the real method or property. The element then draws nothing, with no compile error. Full list: [Layout](/docs/custom-elements/layout).

How signals update: [Signals](/docs/custom-elements/signals).

## Constructor

Pass props to `super`. Then `this.add()` the child tree, the same way a scene adds to its view.

```tsx
public constructor(props?: HelloTitleProps) {
  super({ ...props });
  this.add(
    <Txt
      text={() => this.label()}
      fill={() => this.textColor()}
      fontSize={() => this.textSize()}
      fontFamily="Inter Variable"
      fontWeight={600}
    />,
  );
}
```

Bind child props to functions that read the class signals. A one-shot `text` assignment that calls `label()` will not update when `label` changes.

You can pin a built-in prop in `super` when it must always be on:

```tsx
super({
  layout: true,
  ...props,
});
```

## Animation methods

Generator methods on the class are how you animate the element. The timeline calls `animate()`. Thumbnails play `animateIn()`. Keep them as two methods. Details: [Animation](/docs/custom-elements/animation).

```tsx
public *animateIn(duration: number = 0.4): ThreadGenerator {
  this.opacity(0);
  yield* this.opacity(1, duration, easeOutCubic);
}

public *animate(duration?: number): ThreadGenerator {
  yield* this.animateIn(duration ?? 0.4);
}
```

## Full source

```tsx
import { Node, NodeProps, Txt, signal, initial, colorSignal } from '@vidova/2d';
import {
  SignalValue,
  SimpleSignal,
  ColorSignal,
  PossibleColor,
  easeOutCubic,
  type ThreadGenerator,
} from '@vidova/core';

export interface HelloTitleProps extends NodeProps {
  label?: SignalValue<string>;
  textColor?: SignalValue<PossibleColor>;
  textSize?: SignalValue<number>;
}

export class HelloTitle extends Node {
  @initial('Hello')
  @signal()
  public declare readonly label: SimpleSignal<string, this>;

  @initial('#ffffff')
  @colorSignal()
  public declare readonly textColor: ColorSignal<this>;

  @initial(48)
  @signal()
  public declare readonly textSize: SimpleSignal<number, this>;

  public constructor(props?: HelloTitleProps) {
    super({ ...props });
    this.add(
      <Txt
        text={() => this.label()}
        fill={() => this.textColor()}
        fontSize={() => this.textSize()}
        fontFamily="Inter Variable"
        fontWeight={600}
      />,
    );
  }

  public *animateIn(duration: number = 0.4): ThreadGenerator {
    this.opacity(0);
    yield* this.opacity(1, duration, easeOutCubic);
  }

  public *animate(duration?: number): ThreadGenerator {
    yield* this.animateIn(duration ?? 0.4);
  }
}
```

Place it with [component](/docs/mcp/tools/component) `create` and [timeline_edit](/docs/mcp/tools/timeline_edit) `addClip`. Walkthrough: [Quickstart](/docs/custom-elements/quickstart). Asset slots and `inputDefs`: [Inputs](/docs/custom-elements/inputs). SkSL: [Shaders](/docs/custom-elements/shaders).
