tosijs-product

A cinematic product page component library for tosijs.

tosijs-product provides high-performance, scroll-linked animation components designed to create immersive, "Apple-style" product stories with minimal code. It unifies Lottie, video, BabylonJS 3D, Mapbox flights, themes, and declarative CSS interpolation under a single scroll engine.

This page is the demo. Scroll — the hero below is a live <tosi-product> engine pinned to this doc's scroll container, authored entirely in the Markdown you're reading.

tosijs-product

Build cinematic product pages with HTML.

scroll ↓

Interpolator

A waypoint timeline for any CSS property. Set keyframes by progress and let the engine drive the rest.

<tosi-interpolator>

Staged reveal

Multiple interpolators in one section, each scoped to a slice of progress with data-scroll-range:

✓ Sticky window pins the engine
✓ Stack translates as you scroll
✓ Sections pin then exit
✓ Sub-range staging schedules the rest

Scrub video

A native <video> with data-scroll-animate="currentTime" — the section maps scroll progress to video.currentTime.

Frame-perfect scrubbing, no plugin

100 frames. One image.

Zero video decode. Instant seeking. A WebP/JPG mosaic blitted to a canvas.

Hardware-accelerated canvas blits

Vector animation

Bodymovin / Lottie JSON, scrubbed frame-by-frame by scroll.

Frame-perfect at every zoom

MacBook Neo.

A glTF model, lit and framed by scroll — <tosi-3d> driven by a <tosi-scroll-camera>.

Every angle, scroll-driven.

Half Moon Bay

A Mapbox fly-through, waypoint-driven by <tosi-scroll-map> — no scroll callbacks.

↑ zoom out, fly ↑

Oulu, Finland

View the standalone demo · all the scenes below run the same engine.

Architecture in one paragraph

<tosi-product> is a scroll engine. It owns the page (or any scrollable region) it lives in: it computes a runway from its sections, hosts a sticky viewport-sized window in shadow DOM, and translates an absolute-positioned stack as you scroll. Each <tosi-product-section> declares a pin duration via scroll; during pin the section sits motionless at the viewport top while interpolators run, then it scrolls out at 1:1 and yields to the next. Themes are dictionaries of CSS custom properties — the engine writes the active section's resolved values to :root, so external siblings (page header, sticky overlay, footer) re-theme through the cascade.

Key components

Getting started

Pure HTML (zero JS orchestration)

The IIFE build is self-contained — a single script tag gives you tosijs, tosijsUi, and tosijsProduct as globals, with all custom elements registered automatically:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>My Product</title>
    <style>
      body {
        margin: 0;
      }
    </style>
    <script src="https://cdn.jsdelivr.net/npm/tosijs-product/dist/index.js"></script>
  </head>
  <body>
    <tosi-product>
      <tosi-product-section scroll="200">
        <tosi-interpolator data-scroll-animate easing="ease-in-out">
          <tosi-waypoint
            progress="0"
            style="opacity: 0; transform: translateY(50px)"
          ></tosi-waypoint>
          <tosi-waypoint
            progress="0.5"
            style="opacity: 1; transform: translateY(0px)"
          ></tosi-waypoint>
          <tosi-waypoint
            progress="1"
            style="opacity: 1; transform: scale(1.2)"
          ></tosi-waypoint>
          <h1 style="text-align: center;">Pinned for 2× viewport.</h1>
        </tosi-interpolator>
      </tosi-product-section>
    </tosi-product>
  </body>
</html>

Modern web app (ESM)

Install from npm using bun, npm or whatever package manager you prefer:

bun add tosijs-product tosijs tosijs-ui

And compose your pages using typescript, javascript, or HTML.

import {
  tosiProduct,
  tosiProductSection,
  tosiInterpolator,
  tosiWaypoint,
} from "tosijs-product";

const app = tosiProduct(
  tosiProductSection(
    { scroll: 200 },
    tosiInterpolator(
      { "data-scroll-animate": true, easing: "ease-in-out" },
      tosiWaypoint({
        progress: 0,
        style: "opacity: 0; transform: translateY(50px)",
      }),
      tosiWaypoint({
        progress: 0.5,
        style: "opacity: 1; transform: translateY(0px)",
      }),
      tosiWaypoint({ progress: 1, style: "opacity: 1; transform: scale(1.2)" }),
      document.createElement("h1")
    )
  )
);

document.body.append(app);

The scroll attribute

scroll on a section is its pin duration, expressed as a percentage of the viewport. scroll="200" means "pin this section for 2× viewport of scroll." When pin progress reaches 1, the section enters its exit phase and scrolls out at 1:1 over its own height. So total scroll claimed = (scroll / 100) * viewport + naturalSize.

Themes

Register themes (each is a dictionary of CSS custom properties) and reference them from sections:

const app = tosiProduct(
  tosiProductSection({ scroll: 100, theme: "midnight" } /* ... */),
  tosiProductSection(
    { scroll: 200, "theme-from": "midnight", "theme-to": "paper" } /* ... */
  ),
  tosiProductSection({ scroll: 100, theme: "paper" } /* ... */)
);

app.themes = {
  midnight: { "--bg": "#08081a", "--fg": "#f0f0f5", "--accent": "#9be7ff" },
  paper: { "--bg": "#f5f1e8", "--fg": "#1a1815", "--accent": "#7c3aed" },
};
app.defaultTheme = "midnight";

The transition section interpolates its CSS variables (color values use color-mix(in srgb, …)) over its pin progress, and writes them to document.documentElement. Anything cascading from :root — including a <tosi-product-header> overlay outside the engine — re-themes in unison.

Frame-based animation

Standard video scrubbing (video.currentTime) often stutters because decoders aren't designed for random-access seeking. The tosi-mosaic CLI converts a video to a single WebP mosaic grid, and <tosi-filmstrip> scrubs through it using a hardware-accelerated canvas.

bunx tosi-mosaic my-video.mp4 --frames 100 --width 1280

Produces my-video_10x10_100.webp (the filename encodes grid + total frames):

<tosi-filmstrip
  src="my-video_10x10_100.webp"
  data-scroll-animate
></tosi-filmstrip>

A grid (rather than a single long strip) keeps dimensions inside the browser's max image size (commonly 16,384px) while delivering all frames in one request.

License

Licensed under the Apache License, Version 2.0. See LICENSE for details.