Docs

Authoring a custom element

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

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 sets its animation from time in atTime.

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

The timeline calls atTime(time, duration) for every frame it shows, in any order. Set every animated property from time on every call, and keep no state between calls. defaultDuration is the length used for a thumbnail, which poses the element at 65% of it. animate() still works as a fallback. Details: Animation.

public readonly defaultDuration = 3;
 
public atTime(time: number): void {
  this.opacity(easeOutCubic(clamp(0, 1, time / 0.4)));
}

Full source

import { Node, NodeProps, Txt, signal, initial, colorSignal } from '@vidova/2d';
import {
  SignalValue,
  SimpleSignal,
  ColorSignal,
  PossibleColor,
  clamp,
  easeOutCubic,
} 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}
      />,
    );
  }
 
  /** Clip length when nothing gives one, such as a thumbnail. */
  public readonly defaultDuration = 3;
 
  public atTime(time: number): void {
    this.opacity(easeOutCubic(clamp(0, 1, time / 0.4)));
  }
}

Place it with component create and timeline_edit addClip. Walkthrough: Quickstart. Asset slots and inputDefs: Inputs. SkSL: Shaders.