Keyframes are stored on clips and evaluated by the core engine, not by a demo or renderer-specific layer. That keeps animated clip properties available to playback adapters, custom DOM renderers, canvas layers, inspectors, undo/redo, and app-owned serialization.
Canvas Timeline currently ships first-party keyframes for clip opacity. The model is intentionally small and extendable: each keyframe belongs to one clip, targets one supported property, stores an absolute RationalTime, and defines the interpolation for the outgoing segment to the next keyframe.
import { fromSeconds, type Track } from '@techsquidtv/canvas-timeline';
const tracks: Track<'visual'>[] = [ { id: 'visual-1', kind: 'visual', name: 'Visual', selected: true, locked: false, muted: false, visible: true, targeted: true, clips: [ { id: 'intro', sourceId: 'source-intro', timelineStart: fromSeconds(0), timelineEnd: fromSeconds(8), sourceStart: fromSeconds(0), selected: true, keyframes: [ { id: 'fade-in-start', property: 'opacity', time: fromSeconds(0), value: 0 }, { id: 'fade-in-end', property: 'opacity', time: fromSeconds(2), value: 1 }, { id: 'fade-out-start', property: 'opacity', time: fromSeconds(6), value: 1, interpolation: 'bezier', easing: { x1: 0.42, y1: 0, x2: 0.58, y2: 1 }, }, { id: 'fade-out-end', property: 'opacity', time: fromSeconds(8), value: 0 }, ], }, ], },];Engine API
Use the core engine for persistence-safe keyframe edits and evaluation. Keyframe times are clamped to their owning clip range, opacity values are clamped to 0..1, and locked tracks reject keyframe mutations.
import { TimelineEngine, fromSeconds } from '@techsquidtv/canvas-timeline';
const engine = new TimelineEngine({ tracks });
engine.setClipKeyframe({ clipId: 'intro', property: 'opacity', time: fromSeconds(3), value: 0.6, interpolation: 'linear',});
const opacity = engine.getClipPropertyValueAtTime('intro', 'opacity', fromSeconds(3.5));setClipKeyframe() adds or updates a keyframe at an exact clip/property/time. When you omit interpolation for a brand-new keyframe, it inherits the interpolation mode (and Bezier easing) of the previous keyframe in the same property lane, so placing a keyframe mid-segment keeps the segment’s easing character instead of resetting it to linear. Use updateClipKeyframe() when you already have a keyframe id, and removeClipKeyframe() when your application decides a keyframe should be deleted.
updateClipKeyframe() merges keyframes that land on the same property/time only for committed edits. Preview updates ({ commit: false }, used by drag hooks) never delete a colliding neighbor; the dragged keyframe holds its last non-colliding time instead, so scrubbing a keyframe across another one is non-destructive.
Interpolation
Interpolation belongs to the keyframe on the left side of a segment:
| Mode | Behavior |
|---|---|
linear |
Interpolates evenly from this keyframe to the next keyframe. |
hold |
Keeps this keyframe’s value until the next keyframe is reached. |
bezier |
Uses cubic Bezier easing control points for the outgoing segment. |
Bezier easing uses TimelineCubicBezier control points:
engine.updateClipKeyframe({ clipId: 'intro', keyframeId: 'fade-out-start', interpolation: 'bezier', easing: { x1: 0.16, y1: 1, x2: 0.3, y2: 1 },});The evaluator, canvas renderer, and curve-handle geometry share the same interpolation helpers, so playback values, drawn curves, and draggable Bezier handles agree for linear, hold, and bezier.
React Hooks
Use useTimelineKeyframes() for headless keyframe state, geometry, evaluation, and commands:
import { fromSeconds } from '@techsquidtv/canvas-timeline-utils';import { useTimelineKeyframes } from '@techsquidtv/canvas-timeline-react/hooks';
function OpacityButton() { const keyframes = useTimelineKeyframes({ clipId: 'intro', property: 'opacity' });
return ( <button type="button" onClick={() => keyframes.setKeyframe({ clipId: 'intro', property: 'opacity', time: fromSeconds(4), value: 0.5, }) } > Add opacity keyframe </button> );}For custom keyframe handles, compose useTimelineKeyframeDrag() with your own DOM or canvas hit targets. The hook handles preview-time updates and settles history when the drag ends, while your UI owns pointer wiring, styling, and policy.
For Bezier curve editing, use useTimelineKeyframeCurves() to read segment geometry and useTimelineKeyframeCurveDrag() to update easing handles during pointer drags:
import { useTimelineKeyframeCurveDrag, useTimelineKeyframeCurves,} from '@techsquidtv/canvas-timeline-react/hooks';
function CurveHandles() { const curves = useTimelineKeyframeCurves({ property: 'opacity', selectedClipOnly: true, selectedKeyframeOnly: true, }); const drag = useTimelineKeyframeCurveDrag({ property: 'opacity' });
return curves.visibleCurveHandles.map((handle) => ( <button key={`${handle.segmentId}:${handle.handle}`} type="button" onPointerDown={() => drag.startKeyframeCurveDrag({ clipId: handle.clip.id, segmentId: handle.segmentId, keyframeId: handle.keyframe.id, handle: handle.handle, curveHandle: handle, }) } /> ));}Interaction Layers
The default canvas timeline draws keyframe curves and diamonds inside clips through CanvasRenderer. Add Timeline.KeyframeInteractionLayer when you want draggable DOM handles over those canvas visuals:
import { CanvasRenderer, Timeline } from '@techsquidtv/canvas-timeline';
<Timeline.Root> <CanvasRenderer /> <Timeline.ClipInteractionLayer /> <Timeline.KeyframeInteractionLayer property="opacity" selectedClipOnly onKeyframeDoubleClick={(entry, { engine }) => { engine.removeClipKeyframe(entry.clip.id, entry.keyframe.id); }} /> <Timeline.PlayheadGrabber /></Timeline.Root>;The interaction layer keeps its root pointer-events: none and only enables pointer events on visible keyframe handles, so clips remain interactive between handles. Each handle wraps its visual shape in an invisible padded hit target (hitPadding, 8px by default) so near-miss presses grab the keyframe instead of starting a clip move or trim on the clip layer underneath. Use keyframeSize, hitPadding, keyframeValuePadding, getKeyframeAriaLabel, onKeyframeDoubleClick, onKeyframeDelete, and onKeyDown to adapt the layer for your editor.
Add Timeline.KeyframeCurveInteractionLayer when you want optional inline Bezier handles for selected curve segments:
<Timeline.KeyframeCurveInteractionLayer property="opacity" selectedClipOnly selectedKeyframeOnly onCurveHandleDoubleClick={(handle) => { console.log(handle.segmentId, handle.handle); }}/>The curve layer edits only existing Bezier easing data. Its handles use the same padded hit targets (hitPadding), pointer capture with document-level fallback listeners, and select their anchor keyframe when a drag starts. Apps still decide when to convert a segment to Bezier, when to reset handles, and whether to expose presets, inspector fields, or a separate graph editor.
Custom Renderers
Keyframe behavior is not tied to the built-in canvas renderer. DOM renderers can read keyframeRects or visibleKeyframes from useTimelineKeyframes(), and curveSegments or visibleCurveHandles from useTimelineKeyframeCurves(). Custom canvas layers can read keyframe geometry from TimelineCanvasLayer draw input or call the engine geometry APIs.
Set showKeyframes={false} on CanvasRenderer when you want to draw keyframe curves yourself while keeping the same engine and hook APIs.
The Opacity Keyframes demo shows these pieces together: engine-level opacity evaluation, canvas-rendered curves, DOM drag handles, clip double-click creation, handle deletion, Bezier preset editing, inline Bezier handle dragging, and an HTML video preview whose opacity follows the current playhead.