Docs

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.

View as Markdown

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.

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.

export class HelloTitle extends Node {
  // implementation
}

The exported class name is componentName on the 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.

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.

How signals update: Signals.

Constructor

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

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:

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.

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

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 create and timeline_edit addClip. Walkthrough: Quickstart. Asset slots and inputDefs: Inputs. SkSL: Shaders.