First video

Your first video is a six-second title card, where the word WILDFLOWER rises one letter at a time and a subtitle types itself underneath. The stage shows the page after each step, and where a step changes code you already have, its new lines are marked.

Kinetic is a WildflowerJS extension that makes a video from a web page. You write what the page shows at second t, and the renderer photographs the page thirty times a second. It installs as @wildflowerjs/kinetic and needs WildflowerJS 1.5.5 or later.

You need Chrome. The render draws the page's HTML into the video with HTML-in-Canvas (Chrome's article on the API), which until Chrome 157 is a setting: paste chrome://flags/#canvas-draw-element into Chrome's address bar, set the entry it opens at, HTML-in-Canvas, to Enabled, and restart Chrome. Chrome 157, scheduled for 3 November 2026, turns it on. The page you build here has no files beside it, so it opens from your disk. A page with an image, a sound or a data file beside it needs a web server (Preview, check and render).

The page

Start with an HTML file that loads three scripts and holds one element, the composition, which is the video.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Launch</title>
    <script defer src="https://cdn.jsdelivr.net/npm/wildflowerjs@^1.5.5/dist/wildflower.lite.min.js"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/@wildflowerjs/kinetic@0.1/dist/kinetic.wf.js" data-preview></script>
    <script defer src="scenes.js"></script>
    <style>
        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; }
    </style>
</head>
<body>
    <div data-composition-id="main" data-start="0" data-duration="6"
         data-width="1280" data-height="720" data-fps="30">
    </div>
</body>
</html>

The composition's attributes make the video six seconds long, 1280 by 720 pixels, at 30 frames a second. The stage is blank because the composition is still empty.

More on the page

The three scripts use defer, so they run in order after the page is parsed.

  1. WildflowerJS, here in its lite build. Kinetic adds itself to wildflower when it loads, so the framework comes first.
  2. Kinetic. data-preview on its tag adds the preview bar and scales the composition down to fit the window. kinetic.wf.js is the development build, which prints a warning with a KN code when a scene breaks a rule, and the bar counts them. kinetic.wf.min.js is the same code with the checks left out, for the finished page.
  3. scenes.js, the page's own script. Kinetic reports ready after every deferred script has run, so each scene is registered by then.

The composition's attributes are these.

  • data-composition-id is the composition's name.
  • data-start and data-duration are the start and length in seconds.
  • data-width and data-height are the size in pixels, and data-fps is the frame rate. Kinetic gives the root that size, so the CSS does not repeat it.

The CSS removes the page margin and sets the video's background, text color and font. Until the scenes are bound, the root holds raw markup with every clip visible at once, so the last rule, Kinetic's cloak, hides it until Kinetic sets data-kinetic-ready on it. It is named after WildflowerJS's [data-cloak], and differs from it in two ways.

  • It lifts when Kinetic is ready: the fonts loaded, the media decoded, the effects drawn and the first seek landed. That is later than WildflowerJS's cloak, which lifts when the components initialize.
  • It hides with visibility, which keeps the layout Kinetic measures, where WildflowerJS's cloak uses display: none.
  • Composition. The element a video is made of, with a width, a height, a length and a frame rate.
  • Clip. An element inside the composition with a start time and a duration. It is shown during that time and hidden outside it.
  • Frame. One photograph of the page.
  • Render. To take every frame and write the video file.

Open this step, under the stage, opens the step's page in a tab of its own, where View Source shows the whole file.

A title card

Add a clip to the composition, a card whose heading fades in and rises into place, and give the card two CSS rules.

<div data-composition-id="main" data-start="0" data-duration="6"
     data-width="1280" data-height="720" data-fps="30">
    <div class="card" data-component="title-card" data-start="0.4" data-duration="5">
        <h1 data-bind-style="heading">Launch</h1>
    </div>
</div>
.card {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
}
.card h1 { margin: 0; font-size: 136px; }

The heading's motion is a scene in scenes.js.

const { scene } = wildflower.kinetic;

scene('title-card', {
    keyframes: {
        heading: {
            opacity:    { at: [0, 0.5], to: [0, 1] },
            translateY: { at: [0, 0.6], to: [40, 0], ease: 'spring' }
        }
    }
});

A scene is a component whose values are functions of the time. keyframes turns heading into a style object, and data-bind-style="heading" puts it on the heading. The card is shown from 0.4 seconds for 5 seconds, and the times in at count from the card's start.

More on scenes and tracks

A component is an element with data-component plus the definition registered under that name, which supplies the values that the element's data-bind attributes show. wildflower.kinetic.scene() registers a scene.

Every scene has two time values, time, the composition's seconds, and local, the seconds since the scene's clip started, which is time - 0.4 for this card. The times in at are local seconds.

A track lists times in at and the value at each time in to. Opacity goes from 0 to 1 over the first half second, and translateY moves the heading from 40 pixels below its place to 0 on a spring, which overshoots and settles.

translateX, translateY, rotate and scale fold into one transform, and a blur track becomes filter: blur(). A track holds its first value before its first time and its last value after its last time.

The card fills the composition and centers its content.

The subtitle types itself

Add a paragraph under the heading that types a sentence, starting one second into the clip.

<div class="card" data-component="title-card" data-start="0.4" data-duration="5">
    <h1 data-bind-style="heading">Launch</h1>
    <p class="sub" data-bind="typed"></p>
</div>
.sub { margin: 28px 0 0; font-size: 34px; min-height: 1.3em; }
const { scene, typewriter } = wildflower.kinetic;

scene('title-card', {
    keyframes: {
        heading: {
            opacity:    { at: [0, 0.5], to: [0, 1] },
            translateY: { at: [0, 0.6], to: [40, 0], ease: 'spring' }
        }
    },
    computed: {
        typed() { return typewriter(this.local - 1, 'Every frame is a function of time.', 28); }
    }
});

typed is a computed, a value worked out again whenever what it reads changes. It reads this.local, so it changes on every frame, and data-bind="typed" shows it as the paragraph's text.

More on the subtitle

typewriter(t, text, cps) returns the first floor(t * cps) characters of the text, here at 28 characters a second. this.local - 1 starts the typing one second into the clip. Before that second, t is below 0 and the text is empty.

min-height holds the line's height while it is empty, so the heading does not move as the text arrives.

Letters one by one

Turn the heading into a list of letters, so each letter rises on its own, 0.06 seconds after the one before. The letters computed takes the place of the heading track.

<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>
.ch { display: inline-block; white-space: pre; }
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); }
    }
});

split() returns a row for each letter. styleAt(t, tracks) evaluates the same tracks that keyframes takes, at time t, and returns a style object for that moment, so each row carries its own letter's style. stagger(i, n, { each: 0.06 }) is letter i's delay, 0.06 seconds for each letter before it, and subtracting it from the time starts each letter that much later.

More on lists and letters

A list is an element with data-list and a <template>. WildflowerJS draws one copy of the template for each item of the array that the list is bound to.

A transform does not apply to an inline element, so each letter needs display: inline-block. white-space: pre keeps the spaces between words.

split(text) returns one row per letter, and split(text, 'word') one per word. A row is { i, key, text, space }, where i is its position, key identifies it, and space is true for whitespace.

Each letter takes its style at local minus 0.2 seconds, and minus its stagger() delay. A letter that has not started holds the first value of every track, so it is invisible. stagger() also takes from: 'center' to start from the middle letter, and total to spread the starts over a set time.

Preview, check and render

Open the page in Chrome, from your disk or a server. The bar at the bottom comes from data-preview, and the stage has the same bar. Drag its scrubber back and forth, and the heading is in the same place at the same time on every pass.

Check sets the time to a sample of frames in two orders and reports the first frame that came out different. Render writes the MP4 in the browser and downloads it as main.mp4, with the HTML-in-Canvas setting from the top of this page. A page can call both.

A render can ask for any frame in any order, so a frame has to depend on its time alone. Read the time from the scene, never the clock or Math.random(). Start no timers and use no CSS transitions in a seek. Format numbers with format(). The development build warns the first time it sees any of these, and Check reports the first frame that differs.

const report = await wildflower.kinetic.check({ samples: 30 });
// { ok: true, frames: 180, samples: 30, ready: 'true', errors: 0 }

const { blob, frames, ms } = await wildflower.kinetic.render({ fps: 30 });
More on checking and rendering

The bar has play and pause, a scrubber, one frame back and forward, the time, Check and Render. A count beside the time shows the KN warnings and errors printed since the page loaded, and on the production build it reads "production build, no diagnostics".

check() sets the time to a shuffled sample of frames, the first and last included, then to the same frames in reverse, and compares what the page held each time. It also counts errors printed or thrown while it seeks, and reads the root's data-kinetic-ready.

Reliability has the rules for a frame that comes out the same, the reports for one that differs, and the development build's warnings.

Rendering in the browser needs the following.

  • Chrome with HTML-in-Canvas, which draws the page's HTML into the video. Until Chrome 157 it is a setting: paste chrome://flags/#canvas-draw-element into Chrome's address bar, set the HTML-in-Canvas entry to Enabled, and restart Chrome (Chrome's article on the API). Without it render() throws KN-201, and the bar says so.
  • The video encoder, which Chrome has on a page served over https, from localhost, or opened from disk. On plain http from another machine render() throws KN-200.
  • A web server, when the page has files beside it. A page opened from disk cannot fetch its own files, so an audio clip, a luma map, an animated image or a data file beside it fails to load, and an image beside it is drawn as nothing in the render, with no warning. This page has none, so it renders from a double-click.

Kinetic sets the time to each frame in turn, draws the page into a canvas, encodes it with the browser's video encoder and writes the MP4 itself. A page with audio clips gets a sound track (Sound in a render), and One page, many videos covers the variables for a batch.

Next

  • Motion has keyframe tracks, easing, timing and layers in depth.
  • Data draws a chart from a query and captions from a voice track.
  • Reliability has the rules for a frame that comes out the same.
  • With an AI assistant starts from a brief, and a brief with no style in it gets the default style.
  • Launch Title is this title card with dust and composition variables.