Timing, staggers and backdrops
This page covers clips and cuts, transitions, several layers in one clip, staggers, holds and ramps, the backdrop, motion that lands on whole frames, and the render.
Clips and timing
A clip is an element with data-start and data-duration in seconds, shown from its start for its duration and hidden outside that window.
data-end, the time the clip ends, can replace data-duration.
data-start also takes the id of another clip.
data-start="intro" starts when the clip intro ends, "intro + 0.5" half a second after that, and "intro - 0.5" half a second before, so the two clips overlap.
data-start="intro.start" starts with intro instead, a form HyperFrames does not read (Starting with another clip).
A cut is a clip that starts when the clip before it ends.
Each clip below uses the card scene and counts its keyframe times from its own start.
The clips run from 0 to 4, 4 to 8, and 7.5 to 11.5 seconds.
<div id="stage" data-composition-id="main" data-start="0" data-duration="12" data-width="1280" data-height="720" data-fps="30">
<div class="layer" id="intro" data-component="card" data-start="0" data-duration="4">
<h1 data-bind-style="heading">One</h1>
</div>
<div class="layer" id="middle" data-component="card" data-start="intro" data-duration="4">
<h1 data-bind-style="heading">Two</h1>
</div>
<div class="layer" data-component="card" data-start="middle - 0.5" data-duration="4">
<h1 data-bind-style="heading">Three</h1>
</div>
</div>
Changing the first clip's data-duration moves every clip that starts from it.
One scene, several clips has the markup for clips of one scene that each show their own text or color.
<div id="stage" data-composition-id="main" data-start="0" data-duration="12" data-width="1280" data-height="720" data-fps="30">
<div class="layer" id="intro" data-component="card" data-start="0" data-duration="4">
<h1 data-bind-style="heading">One</h1>
</div>
<div class="layer" id="middle" data-component="card" data-start="intro" data-duration="4">
<h1 data-bind-style="heading">Two</h1>
</div>
<div class="layer" data-component="card" data-start="middle - 0.5" data-duration="4">
<h1 data-bind-style="heading">Three</h1>
</div>
</div>
wildflower.kinetic.scene('card', {
keyframes: {
heading: {
opacity: { at: [0, 0.5, 3.5, 4], to: [0, 1, 1, 0], ease: 'ease-in-out' },
translateY: { at: [0, 0.8], to: [60, 0], ease: 'cubic-bezier(0.16, 1, 0.3, 1)' },
rotate: { at: [0, 0.8], to: [-8, 0], ease: 'back.out' },
scale: { at: [0, 0.8], to: [0.9, 1], ease: { spring: { stiffness: 170, damping: 14, mass: 1 } } }
}
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
Transitions
data-clip-transition on the third clip turns its half-second overlap with the second into a transition, here a push from the right that slides the second clip out to the left as the third slides in.
The overlap is the transition's length, so "middle - 1" makes the push a second long.
<div class="layer" data-component="card" data-start="middle - 0.5" data-duration="4"
data-clip-transition="push from-right">
<h1 data-bind-style="heading">Three</h1>
</div>
The other kinds are fade, cover, reveal, wipe, iris, clock, zoom, blur and flip, and an ease such as power3.inOut can follow the kind.
A clip takes data-clip-enter="wipe from-left 0.6" to come in over nothing and data-clip-exit="fade 12f" to leave to nothing, where 12f is twelve frames.
Transitions lists every kind and token.
Several layers, one clip
A clip holds any number of layers, each with its own keyframes entry counted from the clip's start.
The scene already has time, local, clipFrame, fps, progress, remaining, enter, exit, presence, vars and inputs, so an entry takes another name.
This clip adds a line and a subtitle under the heading, with their CSS in style.css.
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
<div class="layer" data-component="card" data-start="0.5" data-duration="4">
<h1 data-bind-style="heading">Launch</h1>
<div class="rule" data-bind-style="rule"></div>
<p class="sub" data-bind-style="sub">Every frame is a function of time.</p>
</div>
wildflower.kinetic.scene('card', {
keyframes: {
heading: {
opacity: { at: [0, 0.5, 3.5, 4], to: [0, 1, 1, 0] },
translateY: { at: [0, 0.8], to: [60, 0], ease: 'power3.out' }
},
rule: {
width: { at: [0.4, 1.2], to: [0, 480], unit: 'px', ease: 'power2.out' },
opacity: { at: [3.5, 4], to: [1, 0] }
},
sub: {
opacity: { at: [0.8, 1.3, 3.5, 4], to: [0, 1, 1, 0] },
translateY: { at: [0.8, 1.3], to: [20, 0], ease: 'power2.out' }
}
}
});
The line's width holds 0 until 0.4 seconds, and the subtitle holds its first values until 0.8 seconds.
<div id="stage" data-composition-id="main" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="card" data-start="0.5" data-duration="4">
<h1 data-bind-style="heading">Launch</h1>
<div class="rule" data-bind-style="rule"></div>
<p class="sub" data-bind-style="sub">Every frame is a function of time.</p>
</div>
</div>
wildflower.kinetic.scene('card', {
keyframes: {
heading: {
opacity: { at: [0, 0.5, 3.5, 4], to: [0, 1, 1, 0] },
translateY: { at: [0, 0.8], to: [60, 0], ease: 'power3.out' }
},
rule: {
width: { at: [0.4, 1.2], to: [0, 480], unit: 'px', ease: 'power2.out' },
opacity: { at: [3.5, 4], to: [1, 0] }
},
sub: {
opacity: { at: [0.8, 1.3, 3.5, 4], to: [0, 1, 1, 0] },
translateY: { at: [0.8, 1.3], to: [20, 0], ease: 'power2.out' }
}
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
A stagger
A stagger moves each letter a little after the one before it, with a computed and a list.
A computed is a named value recalculated from the time.
data-list repeats the element in its <template> once for each row a computed returns.
<div class="layer" data-component="title" data-start="0" data-duration="4">
<h1 data-list="letters" data-key="key">
<template><span style="display: inline-block; white-space: pre" data-bind="text" data-bind-style="style"></span></template>
</h1>
</div>
const { split, styleAt, stagger } = wildflower.kinetic;
const rise = {
opacity: { at: [0, 0.35], to: [0, 1] },
translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
scale: { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};
wildflower.kinetic.scene('title', {
computed: {
letters() {
const rows = split('WILDFLOWER');
return rows.map((r) => ({
key: r.key,
text: r.text,
style: r.space ? {} : styleAt(this.local - 0.2 - stagger(r.i, rows.length, { each: 0.06 }), rise)
}));
}
}
});
The computed uses three helpers from wildflower.kinetic.
split('WILDFLOWER')makes one row per letter, with an indexi, akey, itstextand aspaceflag.styleAt(t, tracks)evaluates a block of tracks, in the form of akeyframesentry, at the timet, and returns a style object for that moment. A letter whose time is below a track's first time holds the track's first value.stagger(i, n, { each: 0.06 })gives letteriofna delay of 0.06 seconds per letter before it.from: 'center'starts the letters from the middle of the word outward, andtotal: 0.5spreads the starts over half a second whatever the word's length (stagger() has the other options).
Each letter takes its style at this.local - 0.2 minus its delay, where this.local is the seconds since the clip started.
The first letter starts 0.2 seconds in, and each later one 0.06 seconds after the one before.
The span needs display: inline-block for a transform to move it, and white-space: pre because split() gives each space its own row, whose style the computed leaves empty.
A blur track becomes filter: blur(), and any other CSS function value, such as filter: brightness(1.2), cannot be a track.
A computed builds that string, as the next section does.
The Launch Title demo adds a rule, a typed subtitle and drifting dust to this title.
<div id="stage" data-composition-id="main" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="title" data-start="0" data-duration="4">
<h1 data-list="letters" data-key="key">
<template><span style="display: inline-block; white-space: pre" data-bind="text" data-bind-style="style"></span></template>
</h1>
</div>
</div>
const { split, styleAt, stagger } = wildflower.kinetic;
const rise = {
opacity: { at: [0, 0.35], to: [0, 1] },
translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
scale: { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};
wildflower.kinetic.scene('title', {
computed: {
letters() {
const rows = split('WILDFLOWER');
return rows.map((r) => ({
key: r.key,
text: r.text,
style: r.space ? {} : styleAt(this.local - 0.2 - stagger(r.i, rows.length, { each: 0.06 }), rise)
}));
}
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
Holds and ramps
edges sets the length of a ramp at each end of the clip.
edges: { enter: 0.4, exit: 0.6 } makes enter rise from 0 to 1 over the first 0.4 seconds, and exit from 0 to 1 over the last 0.6, ending on the clip's last frame in the render.
Without edges, enter is 1 and exit is 0.
presence is enter * (1 - exit), 0 at both ends of the clip and 1 between.
Below, away is its inverse, so the layer is transparent and blurred by 16 px at the ends and sharp and opaque between.
On a whole clip, data-clip-enter="blur 0.4" and data-clip-exit="blur 0.6" give a look like this in the markup alone, and are the default; edges are for a value the scene's own code reads (Which to use).
<div class="layer" data-component="sale" data-start="0.5" data-duration="4">
<div class="layer" data-bind-style="look">
<h1>Spring sale</h1>
</div>
</div>
wildflower.kinetic.scene('sale', {
edges: { enter: 0.4, exit: 0.6 },
computed: {
look() {
const away = 1 - this.presence;
return {
opacity: String(1 - away),
filter: 'blur(' + (away * 16).toFixed(1) + 'px)'
};
}
}
});
local holds at 0 before the clip and at the clip's length after it, so a value computed from it stops changing outside the clip, where the scene is hidden.
<div id="stage" data-composition-id="main" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="sale" data-start="0.5" data-duration="4">
<div class="layer" data-bind-style="look">
<h1>Spring sale</h1>
</div>
</div>
</div>
wildflower.kinetic.scene('sale', {
edges: { enter: 0.4, exit: 0.6 },
computed: {
look() {
const away = 1 - this.presence;
return {
opacity: String(1 - away),
filter: 'blur(' + (away * 16).toFixed(1) + 'px)'
};
}
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
The backdrop
The root: block styles the clip's own element from the scene's values.
Here the clip is a backdrop with no content, whose background follows bg, a single track and so one color.
It goes first in the stage, because later elements paint over earlier ones.
class, attr, show and directives take the same form as style.
<div class="layer" data-component="backdrop" data-start="0" data-duration="6"></div>
wildflower.kinetic.scene('backdrop', {
keyframes: {
bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
},
root: { style: { background: 'bg' } }
});
A data-bind-style on the clip's own tag reads the scene's values, and so does the root: block.
Inside a component of your own (only a stage you put in a component, or one scene inside another) the binding reads that component's values, which lack the scene's, and development builds warn (WF-521 from WildflowerJS, or KN-154 for one of Kinetic's own values in a scene inside a scene).
The root: block still reads the scene's.
<div id="stage" data-composition-id="main" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="backdrop" data-start="0" data-duration="6"></div>
<div class="layer" data-component="card" data-start="0.5" data-duration="4">
<h1 data-bind-style="heading">Launch</h1>
</div>
</div>
wildflower.kinetic.scene('backdrop', {
keyframes: {
bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
},
root: { style: { background: 'bg' } }
});
wildflower.kinetic.scene('card', {
keyframes: {
heading: {
opacity: { at: [0, 0.5, 3.5, 4], to: [0, 1, 1, 0], ease: 'ease-in-out' },
translateY: { at: [0, 0.8], to: [60, 0], ease: 'cubic-bezier(0.16, 1, 0.3, 1)' },
rotate: { at: [0, 0.8], to: [-8, 0], ease: 'back.out' },
scale: { at: [0, 0.8], to: [0.9, 1], ease: { spring: { stiffness: 170, damping: 14, mass: 1 } } }
}
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
The backdrop behind the title card
The title card from the first video uses the same backdrop scene, a second scene placed before the card in the composition, whose background color changes over time.
<div data-composition-id="main" data-start="0" data-duration="6"
data-width="1280" data-height="720" data-fps="30">
<div class="backdrop" data-component="backdrop" data-start="0" data-duration="6"></div>
<div class="card" data-component="title-card" data-start="0.4" data-duration="5">
<h1 data-list="letters" data-key="key">
<template><span class="ch" data-bind="text" data-bind-style="style"></span></template>
</h1>
<p class="sub" data-bind="typed"></p>
</div>
</div>
.backdrop { position: absolute; inset: 0; }
Add this scene to the end of scenes.js, whose first line provides scene.
scene('backdrop', {
keyframes: {
bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
},
root: { style: { background: 'bg' } }
});
A track of colors blends from one color to the next, so the background shifts to #181030 at 2.6 seconds and returns at 5.6 seconds.
<div data-composition-id="main" data-start="0" data-duration="6"
data-width="1280" data-height="720" data-fps="30">
<div class="backdrop" data-component="backdrop" data-start="0" data-duration="6"></div>
<div class="card" data-component="title-card" data-start="0.4" data-duration="5">
<h1 data-list="letters" data-key="key">
<template><span class="ch" data-bind="text" data-bind-style="style"></span></template>
</h1>
<p class="sub" data-bind="typed"></p>
</div>
</div>
const { scene, split, styleAt, stagger, typewriter } = wildflower.kinetic;
const RISE = {
opacity: { at: [0, 0.35], to: [0, 1] },
translateY: { at: [0, 0.6], to: [48, 0], ease: 'spring' },
scale: { at: [0, 0.6], to: [0.9, 1], ease: 'power2.out' }
};
scene('title-card', {
computed: {
letters() {
const rows = split('WILDFLOWER');
return rows.map((r) => ({
key: r.key,
text: r.text,
style: r.space ? {} : styleAt(this.local - 0.2 - stagger(r.i, rows.length, { each: 0.06 }), RISE)
}));
},
typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
}
});
scene('backdrop', {
keyframes: {
bg: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] }
},
root: { style: { background: 'bg' } }
});
html, body { margin: 0; }
[data-composition-id] {
position: relative;
overflow: hidden;
background: #0b0f1a;
color: #fff;
font-family: system-ui, sans-serif;
}
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.card {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.card h1 { margin: 0; font-size: 136px; }
.sub { margin: 28px 0 0; font-size: 34px; min-height: 1.3em; }
.ch { display: inline-block; white-space: pre; }
.backdrop { position: absolute; inset: 0; }
Whole frames
A render captures whole frames only, at 30 fps the times 0, 1/30, 2/30 and so on. An impact between two of those times is never shown, only the moments before and after it. A flash tied to the impact starts a frame late, and one shorter than a frame can fall between two captures and never appear.
Put the impact on a frame.
A frame lasts 1 / fps seconds, so a keyframe time written as a frame number over the frame rate, such as 6 / 30, lands on a capture.
The clip's data-start has to fall on a frame too, and data-start="1" is frame 30 at 30 fps.
Development builds warn (KN-114) about a clip whose start or end falls between two frames, with the two nearest frame times.
<div class="layer" data-component="hit" data-start="1" data-duration="1">
<div class="layer" style="background: #fff" data-bind-style="flash"></div>
</div>
const { scene, interpolate } = wildflower.kinetic;
scene('hit', {
computed: {
flash() { return { opacity: interpolate(this.local, [0, 6 / this.fps], [1, 0], { ease: 'power2.out' }) }; }
}
});
The flash is at full opacity on frame 30, the clip's first frame, and fades over the next six, counted in seconds as 6 / this.fps, so the fade stays six frames at any data-fps.
A computed can count frames with this.clipFrame, the frame number within the clip, a whole number on every capture.
The Showreel demo lands a bouncing ball on whole frames, and A landing on a whole frame has the code.
<div id="stage" data-composition-id="main" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30">
<div class="layer" data-component="hit" data-start="1" data-duration="1">
<div class="layer" style="background: #fff" data-bind-style="flash"></div>
</div>
</div>
const { scene, interpolate } = wildflower.kinetic;
scene('hit', {
computed: {
flash() { return { opacity: interpolate(this.local, [0, 6 / this.fps], [1, 0], { ease: 'power2.out' }) }; }
}
});
html, body { margin: 0; background: #0a0b10; }
#stage { position: relative; overflow: hidden; background: #0b0f1a; }
.layer { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
h1 { margin: 0; font: 800 120px system-ui, sans-serif; color: #fff; }
[data-composition-id]:not([data-kinetic-ready]) { visibility: hidden; }
.rule { height: 4px; margin: 28px 0 22px; background: #9d7bff; }
.sub { margin: 0; font: 400 34px system-ui, sans-serif; color: #c9c9dc; }
Render
The Render button on the preview bar writes an MP4 from the page. For each frame the page is set to that time, drawn into a canvas, encoded and added to the file, which downloads at the end, named after the comp's id.
- The button needs Chrome.
- Chrome draws a page's HTML into the video with HTML-in-Canvas (Chrome's article on the API).
Until Chrome 157, scheduled for 3 November 2026, that is a setting: paste
chrome://flags/#canvas-draw-elementinto Chrome's address bar, set the HTML-in-Canvas entry to Enabled, and restart Chrome. Without it the render fails with KN-201. - The page is served over https or from localhost, or opened from disk. On plain http from another machine the render fails with KN-200.
- A page with an image, a sound or a data file beside it needs a web server, since a page opened from disk cannot load its own files into the render.
- Each
<audio>clip in the comp plays in the file's sound track (Audio).
Many videos from one comp
A comp can declare variables that a scene reads as inputs, and the batch script renders the page once for each row of a file with that row's values (One page, many videos).
The same page renders through HyperFrames' command-line renderer with one more script tag (Rendering through HyperFrames).