Skip to content
Docs / Templates & Motion
Build the editor

Text Templates & Motion

One end of an animation can now drive several channels at once. 0.6.0 adds layered text and shape motion, the pure helpers that sample it, and 14 built-in text templates that bundle a look with its motion.

MotionSpec

TextAnimation still has in and out, the one-kind-per-end enum that the properties panel writes (fade, spin, slide-up, slide-down, slide-left, slide-right). It now also has inMotion and outMotion. Each is a MotionSpec and, when present, replaces the enum for that end. The enum is resolved into a MotionSpec internally, so there is one sampling path, not two.

@elah/core (types)
interface MotionSpec {
  /** Opacity at the extreme, 0..1. Rest is 1. Omitted = no opacity ramp. */
  opacity?: number
  /** Horizontal offset from rest at the extreme, normalized 0..1 of stage width. */
  offsetX?: number
  /** Vertical offset from rest at the extreme, normalized 0..1 of stage height. */
  offsetY?: number
  /** Scale MULTIPLIER on the authored scale. 0.8 = 80% of whatever size the author chose. */
  scale?: number
  /** Rotation delta from rest at the extreme, in radians. Positive = clockwise. */
  rotation?: number
  /** Curve for the geometric channels. Defaults: 'quad-out' on an entry, 'quad-in' on an exit. */
  ease?: TextAnimationEasing
  /** Curve for opacity. Defaults to 'linear'. */
  opacityEase?: TextAnimationEasing
}

interface TextAnimation {
  in?: TextAnimationKind
  out?: TextAnimationKind
  durationFrames: number       // ramp length in frames, shared by both ends
  inMotion?: MotionSpec        // replaces `in` when present
  outMotion?: MotionSpec       // replaces `out` when present
}

A spec stores only the far end of the ramp, because the near end is always the clip's authored resting state. On an entry the clip starts at the extreme and arrives at rest as the ramp closes. On an exit it starts at rest and departs to the extreme. Any channel you omit stays at rest.

typescript
// Rise 5% of stage height, grow from 86%, fade in, with a springy overshoot.
// Leave by continuing upward and shrinking a touch.
engine.updateClip(clip.id, trackId, {
  textAnimation: {
    durationFrames: 12,
    inMotion: { opacity: 0, offsetY: 0.05, scale: 0.86, ease: 'back-out' },
    outMotion: { opacity: 0, offsetY: -0.04, scale: 0.96, ease: 'quad-in' },
  },
})

Motion applies to shape clips too. They carry the same structure on Clip.shapeAnimation. One caveat: neither shape renderer applies transform.rotation, so spin is offered for text only (see appliesTo in TEXT_ANIMATION_KINDS) and a rotation channel has no visible effect on a shape.

Easings

TextAnimationEasing is one of ten curves, and TEXT_ANIMATION_EASINGS lists them in declaration order, so a picker can render the list without drifting from the type.

linearquad-inquad-outquad-in-outcubic-outexpo-outback-inback-outelastic-outbounce-out

back-in, back-out and elastic-out overshoot, returning values outside 0 to 1 partway through. That is most of what makes motion look designed rather than a linear slide. bounce-out settles with a series of decaying bounces. Because geometry can pass its target and come back, opacity is clamped where it is used, and opacityEase exists so opacity can stay linear while the geometry overshoots.

Sampling

The resolver turns an animation into an opacity and, for kinds that move or rotate the clip, a concrete Transform. Renderers stay animation-unaware, so GPU preview and export agree. The same pure helpers are public, for custom previews and pickers:

  • sampleTextAnimation(args) returns { opacity, transform? } for one frame. transformis present only when the animation moves or rotates the clip. When it is absent, leave the clip's own transform alone.
  • resolveRampFrames(durationFrames) clamps a stored ramp length to a whole number of frames of at least 1. Zero, negatives, fractions, NaN and Infinity all collapse safely.
  • motionForKind(kind, 'in' | 'out') returns the MotionSpec that one of the enum kinds is shorthand for.
  • TEXT_ANIMATION_KINDS is the list of kinds with a label and an appliesTo of 'both' or 'text-only'.
  • DEFAULT_TEXT_TRANSFORM and DEFAULT_SHAPE_TRANSFORM are the transforms the renderer applies to a clip that has none. Pass the right one as defaultTransform. They differ: a text clip defaults to scale: 1, a shape to scale: 0.5. SLIDE_TRAVEL_NORMALIZED (0.15) and SPIN_TRAVEL_RADIANS (a quarter turn) are the travel distances the enum kinds use.
typescript
import { sampleTextAnimation, DEFAULT_TEXT_TRANSFORM } from '@elah/editor'

const sample = sampleTextAnimation({
  animation: clip.textAnimation,
  localFrame: currentFrame - clip.startFrame, // frames since the clip started
  clipDurationFrames: clip.durationFrames,
  transform: clip.transform,
  defaultTransform: DEFAULT_TEXT_TRANSFORM,   // required, so it cannot be forgotten
})

const opacity = Math.min(clip.opacity ?? 1, sample.opacity)
const placed = sample.transform ?? clip.transform

The entry ramp covers the first durationFrames of the clip and the exit ramp the last. When the two overlap on a short clip, the opacity ramps take the minimum and the travel vectors add, so the clip passes smoothly through rest instead of popping at the crossover.

Built-in Templates

A text template is a named, applyable look: a style, a motion and sometimes a placement. BUILT_IN_TEXT_TEMPLATES holds 14, ordered as a picker should show them (general looks first, positional and decorative after). Look one up by id with findTextTemplate(id), which returns undefined for an id the package does not ship.

Title Card
template-title-card
Display title that punches up into place and drifts away. Opens a section.
Elegant Reveal
template-elegant-reveal
Serif line that settles in slowly and widens away. Editorial.
Kinetic Pop
template-kinetic-pop
Display type that springs in off-axis and snaps flat. Social cuts.
Quote
template-quote
Roomy serif pull-quote on a soft panel. Breathes in and out.
Lower Third
template-lower-third
Name bar that slides in from the left and snaps to a stop. Interviews.
Subtitle
template-subtitle
Legible caption line pinned near the bottom. Deliberately still.
Callout
template-callout
Outlined chip that drops in with a bounce and lifts away. Facts, stats.
Ticker
template-ticker
Small line that drifts steadily leftward the whole time. Credits.
Editorial Stamp
template-editorial-stamp
Fine tracked caps at the top of frame, settling line by line. Magazine masthead.
Brand Drop
template-brand-drop
Heavy display words stacking in one at a time with a snap. Hooks and drops.
Runway Ticker
template-runway-ticker
Small caps crawling steadily along the bottom. Credits, seasons, drop dates.
Price Chip
template-price-chip
Outlined pill that drops in high in frame. Prices, sizes, one-word facts.
Split Title
template-split-title
Two lines closing in from opposite sides to meet mid-frame. Statement titles.
Period Title
template-period-title
Warm serif easing down out of nothing over a long dissolve. Film-title feel.

Each TextTemplate has an id, name, description, a style (TextTemplateStyle), an animation (TextTemplateAnimation) and optional stagger, tracking and placement. Templates describe their ramp as a fraction of the clip, rampFraction, and never touch content: applying one restyles the words, it does not rewrite them.

Stagger and tracking are descriptive

stagger (break one line into several clips that enter in turn) and tracking: 'wide' (fake letterspacing by respacing the characters) are part of what makes some looks what they are, but a single clip cannot express either. applyTextTemplate ignores both, and nothing in the packages executes them. A host that builds a timeline from a template has to implement them itself.

Applying a Template

applyTextTemplate(template, clip) is pure. It reads the clip and returns a Partial<Clip> patch. Hand the patch to engine.updateClip and the change lands on the undo stack and in the saved document like any other edit. Playback and export need nothing extra, because the patch is ordinary clip fields.

typescript
import { applyTextTemplate, findTextTemplate } from '@elah/editor'

const template = findTextTemplate('template-lower-third')
if (template) {
  engine.updateClip(clip.id, trackId, applyTextTemplate(template, clip))
}
  • Switching is clean, not cumulative. Every optional box field (backgroundColor, backgroundOpacity, padding, borderRadius, borderWidth, borderColor) is present in the patch, set to undefinedwhen the template does not use it. Quote (a panel) followed by Title Card (no panel) does not leave the panel behind. The author's own edits to those fields are replaced, which is what applying a template means.
  • Placement merges. A template that sets placement overrides only x and y, keeping the clip's scale, rotation and anchor. A clip with no transform starts from DEFAULT_TEXT_TRANSFORM, so it does not jump. This is correct for text clips only, because shapes default to a different scale.
  • The ramp is resolved per clip. The patch carries textAnimation with inMotion, outMotion and a durationFramescomputed for this clip's length.

resolveTemplateRamp(rampFraction, clipDurationFrames) is that computation, public for hosts that build a timeline from a template instead of applying one to a selected clip. It returns a whole number of frames that is at most half the clip, at most MAX_TEMPLATE_RAMP_FRAMES (30) and at least MIN_TEMPLATE_RAMP_FRAMES (1). Half the clip is the cap because beyond it the entry and exit overlap and the text never reaches full opacity.

Style Presets

A TextStylePreset is smaller than a template: six style fields (fontFamily, fontWeight, fontSize, textAlign, color, opacity) plus an id and name. It cannot express motion, placement or a background chip. The two coexist: presets are the quick “restyle these glyphs” affordance, and templates are the production-ready unit.

  • BUILT_IN_TEXT_STYLE_PRESETS is a plain array of five looks (Subtitle, Title, Caption, Elegant, Highlight). It is not stored and not removable.
  • textStylePresetsStore holds the user-saved ones. It starts empty and has addPreset(preset), which assigns the id, and removePreset(id). It does no persistence of its own.
  • In React, use useTextStylePresetsStore from @elah/react.
typescript
import {
  BUILT_IN_TEXT_STYLE_PRESETS,
  textStylePresetsStore,
} from '@elah/editor'

// Save the clip's current look as a preset.
textStylePresetsStore.getState().addPreset({
  name: 'My title',
  fontFamily: 'Impact',
  fontWeight: 'bold',
  fontSize: 72,
  textAlign: 'center',
  color: '#ffffff',
  opacity: 1,
})

// Apply a preset: copy its style fields onto the clip.
const { id, name, ...style } = BUILT_IN_TEXT_STYLE_PRESETS[0]
engine.updateClip(clip.id, trackId, style)
On this page