Launch Title

The code of the Launch Title demo, 2 files.

Run the demo All demos

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Launch Title | WildflowerJS Demo</title>
    <script defer src="/js/analytics.js"></script>
    <!-- The framework, then Kinetic with its preview bar, then the scenes.
         Kinetic runs the clock and the render itself. -->
    <script defer src="/js/dist/wildflower.lite.min.js"></script>
    <script defer src="/js/extensions/kinetic.wf.min.js" data-preview></script>
    <script defer src="app.js"></script>
    <style>
        * { box-sizing: border-box; }
        html, body { margin: 0; background: #0a0b10; color: #e6e6f0; font-family: system-ui, sans-serif; }
        .demo-toolbar { display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: #12141c; border-bottom: 1px solid rgba(255,255,255,0.08); }
        .demo-toolbar a { display: flex; align-items: center; gap: 10px; text-decoration: none; color: #5fa6a6; font-size: 1rem; }
        .demo-toolbar .logo { height: 32px; }
        .demo-toolbar .brand-wf { font-weight: 520; }
        .demo-toolbar .brand-js { font-weight: 700; }
        .demo-toolbar .demo-info { font-size: 11px; color: rgba(255,255,255,0.6); text-align: right; }
        .demo-toolbar .demo-info strong { color: rgba(255,255,255,0.9); }

        /* The composition is 1280 by 720. With data-preview, Kinetic scales
           the stage down to the window; the stage's layout stays 1280 by
           720, so a render captures the layout the preview shows. */
        /* Hidden until Kinetic marks the root ready: before that, the stage
           holds unbound markup. */
        #stage:not([data-kinetic-ready]) { visibility: hidden; }
        .frame { padding: 24px; }
        #stage { position: relative; width: 1280px; height: 720px; overflow: hidden; background: #0b0f1a; }
        .layer { position: absolute; inset: 0; }
        .bg { position: absolute; inset: 0; }
        .dust { position: absolute; left: 0; top: 0; border-radius: 50%; background: #fff; }
        .card { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
        .title { margin: 0; font-size: 136px; font-weight: 800; letter-spacing: 0.02em; line-height: 1; color: #fff; display: flex; }
        .ch { display: inline-block; white-space: pre; will-change: transform, opacity; }
        .rule { height: 4px; border-radius: 2px; margin: 28px 0 22px; }
        .sub { margin: 0; font-size: 34px; font-weight: 400; color: #c9c9dc; min-height: 1.3em; letter-spacing: 0.01em; }

        .about { max-width: 1280px; margin: 0 auto; padding: 0 24px 96px; color: #a9adc2; font-size: 14px; line-height: 1.55; }
        .about p { margin: 0 0 10px; }
        .about b { color: #e6e6f0; font-weight: 600; }
        .about code { color: #e6e6f0; font-size: 13px; }
    </style>
</head>
<body>
    <div class="demo-toolbar">
        <a href="/demos/">
            <img class="logo" src="/assets/wildflower_96.png" alt="WildflowerJS">
            <span><span class="brand-wf">Wildflower</span><span class="brand-js">JS</span> Demos</span>
        </a>
        <div class="demo-info"><strong>Launch Title</strong>: a title card</div>
    </div>

    <div class="frame">
        <div id="stage" data-composition-id="launch" data-start="0" data-duration="6" data-width="1280" data-height="720" data-fps="30"
             data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"WILDFLOWER"},{"id":"subtitle","type":"string","label":"Subtitle","default":"Every frame is a function of time."},{"id":"accent","type":"color","label":"Accent","default":"#9d7bff"}]'>
            <div class="layer" data-component="launch-dust" data-start="0" data-duration="6">
                <div data-pool="dust" data-key="id"><template><i class="dust" data-bind-style="style"></i></template></div>
            </div>
            <div class="layer" data-component="launch-title" data-start="0.4" data-duration="5.6">
                <div class="bg" data-bind-style="bg"></div>
                <div class="card" data-bind-style="card">
                    <h1 class="title" data-list="letters" data-key="key"><template><span class="ch" data-bind="text" data-bind-style="style"></span></template></h1>
                    <div class="rule" data-bind-style="rule"></div>
                    <p class="sub" data-bind="typed"></p>
                </div>
            </div>
        </div>
    </div>

    <div class="about">
        <p>Press play, or scrub. Every letter and every speck of dust is computed from the time.</p>
    </div>
</body>
</html>

app.js

// Launch title: a composition where every frame is a function of time, in
// two scenes (the dust, the title). Kinetic scales the 1280 x 720 stage to
// the window, and in a render, or with ?embed, shows the stage alone.

const { scene, split, styleAt, stagger, typewriter, spring, ramp, random, noise } = wildflower.kinetic;

// ---- The dust: 240 motes, placed once, styled on every seek ---------------

const DUST = 240;
const SEED = 7;

scene('launch-dust', {
    pools: { dust: {} },
    init() {
        const rnd = random(SEED);
        for (let i = 0; i < DUST; i++) {
            this.pools.dust.add({
                id: i,
                x: rnd() * 1280,
                y: 120 + rnd() * 600,
                size: 1.5 + rnd() * 3,
                delay: rnd() * 1.6,
                style: {},
            });
        }
    },
    seek(local) {
        const fade = 1 - ramp(local, 5.4, 6.0);
        this.pools.dust.forEach((d) => {
            const up = spring(local - d.delay, { damping: 22 });
            const drift = noise(d.id * 0.37 + local * 0.12, SEED) * 36;
            this.pools.dust.update(d.id, {
                style: {
                    width: d.size + 'px',
                    height: d.size + 'px',
                    ...styleAt(local, {
                        opacity: Math.min(1, up) * 0.65 * fade,
                        translateX: d.x + drift,
                        translateY: d.y - up * 48,
                    }),
                },
            });
        });
    },
});

// ---- The title -----------------------------------------------------------

// Each letter fades in over 0.35 s, rises from 48 px below with a spring,
// and settles its scale over 0.6 s. styleAt() reads it at each letter's own
// offset.
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('launch-title', {
    inputs: {
        title: { type: String, default: 'WILDFLOWER' },
        subtitle: { type: String, default: 'Every frame is a function of time.' },
        accent: { type: String, default: '#9d7bff' },
    },
    edges: { enter: 0.3, exit: 0.6 },
    keyframes: {
        bg: { background: { at: [0, 2.6, 5.6], to: ['#0b0f1a', '#181030', '#0b0f1a'] } },
        ruleWidth: { at: [1.0, 1.7], to: [0, 480], ease: 'power2.out', unit: 'px' },
    },
    computed: {
        letters() {
            const rows = split(this.inputs.title, 'letter');
            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),
            }));
        },
        rule() { return { width: this.ruleWidth, background: this.inputs.accent }; },
        typed() { return typewriter(this.local - 1.5, this.inputs.subtitle, 28); },
        card() { return { opacity: String(this.presence) }; },
    },
});