Skip to content

Repository files navigation

react-kino — cinematic scroll-driven storytelling for React

npm version bundle size license

react-kino

Cinematic scroll-driven storytelling for React.
Core scroll engine under 1 KB gzipped.


Why react-kino

  • Tiny -- the core scroll engine is under 1 KB gzipped. GSAP ScrollTrigger alone is 33 KB.
  • Declarative -- compose <Scene>, <Reveal>, <ScrollTransform>, <Parallax>, <Counter>, <CompareSlider>, <HorizontalScroll>, <VideoScroll>, <Progress>, <StickyHeader>, <Marquee>, and <TextReveal> like regular React components. No imperative timelines.
  • Lightweight runtime -- react-kino uses a tiny internal engine package (@react-kino/core) plus React peers.
  • SSR-safe -- every component renders children on the server and animates on the client.
  • Typed -- every component ships its prop types (SceneProps, RevealProps, ...) plus a typed EasingName union for autocomplete on easing props.
  • Resize-safe -- pinned scenes recompute on viewport resize and mobile browser URL-bar show/hide, so progress never desyncs.

Installation

npm install react-kino
pnpm add react-kino
bun add react-kino

Requirements: React 18+

Quick Start

import { Kino, Scene, Reveal, Counter } from "react-kino";

function App() {
  return (
    <Kino>
      {/* A pinned scene that spans 300vh of scroll distance */}
      <Scene duration="300vh">
        {(progress) => (
          <div style={{ height: "100vh", display: "grid", placeItems: "center" }}>
            <Reveal animation="fade-up" at={0}>
              <h1>Welcome</h1>
            </Reveal>

            <Reveal animation="scale" at={0.3}>
              <p>Scroll-driven storytelling, made simple.</p>
            </Reveal>

            <Reveal animation="fade" at={0.6}>
              <Counter from={0} to={10000} format={(n) => `${n.toLocaleString()}+ users`} />
            </Reveal>
          </div>
        )}
      </Scene>
    </Kino>
  );
}

That is a complete scroll experience: the section pins in place, content fades in at different scroll points, and a number counts up -- all in ~20 lines.


Scaffolding with the CLI

Skip the setup -- scaffold a complete scroll page from a template:

npx @react-kino/cli init
  ✦ react-kino — cinematic scroll experiences for React

  ? What would you like to scaffold? ›
  ❯ Product Launch page
    Case Study page
    Portfolio page
    Blank scroll page

  ? Project name › my-launch-page

  ✓ Created src/app.tsx
  ✓ Created src/page.tsx (Next.js App Router)

  Done! Add react-kino and start scrolling.

Pre-built Templates

@react-kino/templates ships three full-page scroll experiences you can drop in and customize:

npm install @react-kino/templates
import { ProductLaunch } from "@react-kino/templates/product-launch";

<ProductLaunch
  name="Your Product"
  tagline="The tagline that changes everything."
  accentColor="#dc2626"
  stats={[
    { value: 10000, label: "Users", format: (n) => `${n.toLocaleString()}+` },
    { value: 99, label: "Uptime", format: (n) => `${n}%` },
  ]}
  features={[
    { title: "Tiny core", description: "Core engine under 1 KB gzipped.", icon: "⚡" },
    { title: "GPU accelerated", description: "Compositor-only properties.", icon: "🚀" },
  ]}
/>
Template Import Description
ProductLaunch @react-kino/templates/product-launch Apple-style launch page with hero, stats, and feature panels
CaseStudy @react-kino/templates/case-study Portfolio project page with challenge/solution/results
Portfolio @react-kino/templates/portfolio Personal portfolio with bio, projects, and contact

Components

<Kino>

Root provider that initializes the scroll tracking engine. Wrap your app or page layout.

import { Kino } from "react-kino";

<Kino>
  {/* your scenes and content */}
</Kino>
Prop Type Default Description
children ReactNode -- Child elements

<Scene>

A pinned scroll section. Content stays fixed in the viewport while the user scrolls through the scene's duration. This is the core building block.

<Scene> creates a tall spacer element (matching duration) with a sticky inner container. As the user scrolls through the spacer, progress goes from 0 to 1. Children can be static ReactNode or a render function that receives the current progress.

import { Scene } from "react-kino";

{/* Static children -- use child components that read progress from context */}
<Scene duration="200vh">
  <MyAnimatedContent />
</Scene>

{/* Render prop -- get progress directly */}
<Scene duration="400vh">
  {(progress) => (
    <div style={{ opacity: progress }}>
      {Math.round(progress * 100)}% scrolled
    </div>
  )}
</Scene>
Prop Type Default Description
duration string -- Scroll distance the scene spans. Supports vh and px units (e.g. "200vh", "1500px")
pin boolean true Whether to pin (sticky) the inner content during scroll
children ReactNode | (progress: number) => ReactNode -- Static content or render function receiving progress (0-1)
className string -- CSS class for the outer spacer element
style CSSProperties -- Inline styles for the sticky inner container

Context: <Scene> provides a SceneContext that child components (<Reveal>, <Counter>, <CompareSlider>) automatically read from. You do not need to pass progress manually.

Resize handling: <Scene> recomputes its duration and progress whenever the viewport resizes -- including a mobile browser's URL bar showing or hiding -- so pinned content never desyncs. Resize bursts are debounced and coalesced into a single requestAnimationFrame-scheduled update.

Scene component demo


<Reveal>

Scroll-triggered entrance animation. Place inside a <Scene> or provide a progress prop directly.

import { Reveal } from "react-kino";

<Scene duration="300vh">
  <Reveal animation="fade-up" at={0.2}>
    <h2>Appears at 20% scroll</h2>
  </Reveal>

  <Reveal animation="blur" at={0.5} duration={800} delay={200}>
    <p>Blurs in at 50% with a delay</p>
  </Reveal>
</Scene>
Prop Type Default Description
at number 0 Progress value (0-1) when animation triggers
animation RevealAnimation "fade" Animation preset (see below)
duration number 600 Animation duration in milliseconds
delay number 0 Delay before animation starts in milliseconds
progress number -- Direct progress override (0-1). If omitted, reads from parent <Scene> context
children ReactNode -- Content to reveal
className string -- CSS class for the wrapper div

Animation presets:

Preset Effect
"fade" Opacity 0 to 1
"fade-up" Fade in + slide up 40px
"fade-down" Fade in + slide down 40px
"scale" Fade in + scale from 0.9 to 1
"blur" Fade in + unblur from 12px

Reveal animation demo


<ScrollTransform>

Continuously interpolates CSS transforms and opacity between two states as the user scrolls. Unlike <Reveal> (which is a one-shot entrance), ScrollTransform tracks scroll position every frame and reverses when scrolling back. Designed for 3D perspective effects that Reveal can't express.

import { ScrollTransform } from "react-kino";

<Scene duration="350vh">
  <ScrollTransform
    from={{ rotateX: 40, rotateY: -12, scale: 0.82, opacity: 0.3 }}
    to={{ rotateX: 0, rotateY: 0, scale: 1, opacity: 1 }}
    perspective={1200}
    easing="ease-out-cubic"
    transformOrigin="center bottom"
  >
    <div className="card">Your content</div>
  </ScrollTransform>
</Scene>
Prop Type Default Description
from TransformState -- Starting transform state
to TransformState -- Ending transform state
at number 0 Progress value (0-1) when transform begins
span number 1 How much of the progress range the transform spans
easing string | (t: number) => number "ease-out" Easing preset name or custom function
perspective number -- CSS perspective in px (enables 3D transforms)
transformOrigin string "center center" CSS transform-origin
progress number -- Direct progress override. If omitted, reads from parent <Scene> context
className string -- CSS class for the wrapper div
style CSSProperties -- Inline styles (merged with computed transform)

TransformState properties: x, y, z (px), scale, scaleX, scaleY, rotate, rotateX, rotateY (deg), skewX, skewY (deg), opacity (0-1).


<Parallax>

A layer that scrolls at a different speed than the page, creating depth.

import { Parallax } from "react-kino";

{/* Background image scrolls at half speed */}
<Parallax speed={0.3}>
  <img src="/hero-bg.jpg" alt="" style={{ width: "100%", height: "120vh", objectFit: "cover" }} />
</Parallax>

{/* Foreground element scrolls faster */}
<Parallax speed={1.5}>
  <div className="floating-badge">New</div>
</Parallax>
Prop Type Default Description
speed number 0.5 Speed multiplier. 1 = normal scroll, < 1 = slower (background feel), > 1 = faster (foreground feel)
direction "vertical" | "horizontal" "vertical" Scroll direction for the parallax offset
children ReactNode -- Content to apply parallax to
className string -- CSS class
style CSSProperties -- Inline styles (merged with transform)

Parallax depth effect demo


<Counter>

An animated number that counts between two values as the user scrolls. Automatically reads progress from a parent <Scene>.

import { Counter } from "react-kino";

<Scene duration="200vh">
  <Counter from={0} to={1000000} at={0.2} span={0.5} />

  <Counter
    from={0}
    to={99.9}
    format={(n) => `${n.toFixed(1)}%`}
    easing="ease-in-out"
  />
</Scene>
Prop Type Default Description
from number -- Starting value
to number -- Ending value
at number 0 Progress value (0-1) when counting begins
span number 0.3 How much of the progress range (0-1) the count spans
format (value: number) => string toLocaleString Formatting function for the displayed value
easing string | (t: number) => number "ease-out" Easing preset name or custom easing function
progress number -- Direct progress override (0-1). If omitted, reads from parent <Scene> context
className string -- CSS class for the <span> element

When both from and to are integers, the displayed value is automatically rounded.

Counter animation demo


<CompareSlider>

A before/after comparison slider. Supports both drag interaction and scroll-driven modes.

import { CompareSlider } from "react-kino";

{/* Interactive drag mode */}
<CompareSlider
  before={<img src="/before.jpg" alt="Before" />}
  after={<img src="/after.jpg" alt="After" />}
/>

{/* Scroll-driven mode inside a Scene */}
<Scene duration="200vh">
  <CompareSlider
    scrollDriven
    before={<img src="/before.jpg" alt="Before" />}
    after={<img src="/after.jpg" alt="After" />}
  />
</Scene>
Prop Type Default Description
before ReactNode -- Content shown on the "before" side (always visible underneath)
after ReactNode -- Content shown on the "after" side (revealed via clip)
scrollDriven boolean false If true, slider position follows scroll progress instead of drag
progress number -- Progress override (0-1). When scrollDriven, defaults to parent <Scene> context
initialPosition number 0.5 Initial slider position (0-1) in drag mode
ariaLabel string "Comparison slider" Accessible label for the drag handle (role="slider")
className string -- CSS class for the container

The handle is a fully accessible slider: it exposes role="slider" with aria-valuenow/aria-valuemin/aria-valuemax, is keyboard-operable (ArrowLeft/ArrowRight nudge by 5%, Home/End jump to the ends), and is excluded from the tab order when scrollDriven is set (since its position isn't user-adjustable in that mode).

CompareSlider before/after reveal demo


<HorizontalScroll>

Converts vertical scroll into horizontal movement. Wrap <Panel> components inside it.

import { HorizontalScroll, Panel } from "react-kino";

<HorizontalScroll>
  <Panel>
    <div style={{ background: "#111", color: "#fff", padding: 60 }}>
      <h2>Panel One</h2>
    </div>
  </Panel>
  <Panel>
    <div style={{ background: "#222", color: "#fff", padding: 60 }}>
      <h2>Panel Two</h2>
    </div>
  </Panel>
  <Panel>
    <div style={{ background: "#333", color: "#fff", padding: 60 }}>
      <h2>Panel Three</h2>
    </div>
  </Panel>
</HorizontalScroll>

<HorizontalScroll> props:

Prop Type Default Description
children ReactNode -- <Panel> components
className string -- CSS class for the outer spacer
panelHeight string "100vh" Height of each panel as a CSS string

<Panel> props:

Prop Type Default Description
children ReactNode -- Panel content
className string -- CSS class
style CSSProperties -- Inline styles (merged with default 100vw x 100vh sizing)

The spacer height is automatically set to childCount * 100vh, giving each panel a full viewport of scroll distance. Recomputes on window resize (incl. mobile URL-bar show/hide) so panel offsets stay correct. prefers-reduced-motion: panels render without the scroll-linked horizontal transform instead of scroll-jacking the user sideways.


<Progress>

A fixed scroll progress indicator. Supports bar, dots, and ring styles.

import { Progress } from "react-kino";

{/* Simple top bar */}
<Progress />

{/* Ring indicator in the corner */}
<Progress type="ring" position="bottom" color="#10b981" ringSize={40} />

{/* Dot pagination on the right */}
<Progress type="dots" position="right" dotCount={8} color="#fff" />
Prop Type Default Description
type "bar" | "dots" | "ring" "bar" Visual style of the indicator
position "top" | "bottom" | "left" | "right" "top" Fixed position on screen
color string "#3b82f6" Color of the progress fill / active dots / ring stroke
trackColor string "transparent" Background / inactive color
progress number -- Progress override (0-1). If omitted, reads page scroll progress
dotCount number 5 Number of dots (only for "dots" type)
ringSize number 48 Diameter in pixels (only for "ring" type)
className string -- CSS class for the wrapper

<VideoScroll>

Scrubs through a video as the user scrolls — like the AirPods Pro / iPhone product pages. Pair with overlay children for animated text on top of the video.

import { VideoScroll } from "react-kino";

<VideoScroll src="/product.mp4" duration="400vh" poster="/poster.jpg">
  {(progress) => (
    <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
      <h2 style={{ opacity: progress, color: "#fff", fontSize: "4rem" }}>
        Scroll to reveal
      </h2>
    </div>
  )}
</VideoScroll>
Prop Type Default Description
src string -- URL of the video file (MP4 recommended, no audio needed)
duration string "300vh" Scroll distance the video scrubbing spans
pin boolean true Whether to pin the video while scrubbing
poster string -- Poster image shown before the video loads
children ReactNode | (progress: number) => ReactNode -- Overlay content rendered on top of the video
className string -- CSS class for the outer spacer

The video is muted, playsInline, and never autoplays. currentTime is set directly from scroll progress, and re-synced as soon as the video's loadedmetadata event fires -- so slow-loading videos no longer show a stale first frame. prefers-reduced-motion: video stays on the poster frame.


<StickyHeader>

A sticky navigation bar that transitions from transparent to a solid background with backdrop blur as the user scrolls past a threshold.

import { StickyHeader } from "react-kino";

<StickyHeader threshold={40} background="rgba(0, 0, 0, 0.72)" blur>
  <div style={{ maxWidth: 980, margin: "0 auto", height: 48, display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0 24px" }}>
    <span>My Site</span>
    <nav>
      <a href="#features">Features</a>
      <a href="#pricing">Pricing</a>
    </nav>
  </div>
</StickyHeader>
Prop Type Default Description
threshold number 80 Scroll distance (px) before the header becomes solid
background string "rgba(0,0,0,0.8)" Background color when scrolled past threshold
blur boolean true Whether to apply backdrop blur when scrolled
children ReactNode -- Header content
className string -- CSS class
style CSSProperties -- Inline styles

<Marquee>

An infinitely scrolling ticker. Items are automatically duplicated to create a seamless loop. Respects prefers-reduced-motion by falling back to a static flex layout.

import { Marquee } from "react-kino";

<Marquee speed={30} direction="left" pauseOnHover>
  <span>React</span>
  <span>TypeScript</span>
  <span>Next.js</span>
  <span>Tailwind</span>
</Marquee>
Prop Type Default Description
speed number 40 Speed in pixels per second
direction "left" | "right" "left" Scroll direction
pauseOnHover boolean true Pause animation on hover
gap number 32 Gap between items in px
children ReactNode -- Items to scroll
className string -- CSS class

<TextReveal>

Word-by-word, character-by-character, or line-by-line text reveal driven by scroll progress.

import { TextReveal } from "react-kino";

<Scene duration="300vh">
  {(progress) => (
    <TextReveal progress={progress} mode="word" at={0.1} span={0.7}>
      Scroll-driven storytelling components for React. Build cinematic experiences without the complexity.
    </TextReveal>
  )}
</Scene>
Prop Type Default Description
children string -- The text to reveal
mode "word" | "char" | "line" "word" How to split the text into tokens
at number 0 Progress value (0-1) when reveal starts
span number 0.8 How much of the progress range the full reveal spans
color string currentColor Color of revealed tokens
dimColor string -- Color of unrevealed tokens (default: 15% opacity)
progress number -- Direct progress override. If omitted, reads from parent <Scene> context
className string -- CSS class for the wrapper

prefers-reduced-motion: all text renders immediately at full opacity.

TextReveal animation demo


Hooks

useScrollProgress()

Returns the page-level scroll progress as a number from 0 to 1.

import { useScrollProgress } from "react-kino";

function ScrollPercentage() {
  const progress = useScrollProgress();
  return <div>{Math.round(progress * 100)}%</div>;
}

Returns: number -- progress from 0 (top of page) to 1 (bottom of page).


useSceneProgress(ref, durationPx)

Returns scene-level scroll progress for a specific element. Useful when building custom scroll-driven components outside of <Scene>.

import { useRef } from "react";
import { useSceneProgress } from "react-kino";

function CustomScene() {
  const ref = useRef<HTMLDivElement>(null);
  const progress = useSceneProgress(ref, 1500); // 1500px scroll distance

  return (
    <div ref={ref} style={{ height: 1500 }}>
      <div style={{ position: "sticky", top: 0 }}>
        Progress: {progress.toFixed(2)}
      </div>
    </div>
  );
}

Parameters:

Param Type Description
spacerRef RefObject<HTMLElement | null> Ref to the spacer/container element
durationPx number Total scroll distance in pixels

Returns: number -- progress from 0 to 1. Re-renders each frame; for hot animations use the value hooks below.


useSceneProgressValue() / useScrollProgressValue() -- the fast path

Return a stable ProgressValue (a Motion-style motion value) that you subscribe to imperatively and write to the DOM directly — no re-render per scroll frame. useSceneProgressValue() reads the enclosing <Scene>; useScrollProgressValue() tracks whole-page scroll. This is exactly what the built-in components use internally.

import { useRef, useEffect } from "react";
import { useSceneProgressValue } from "react-kino";

function FastFade() {
  const ref = useRef<HTMLDivElement>(null);
  const progress = useSceneProgressValue();
  useEffect(() => {
    const apply = (p: number) => (ref.current!.style.opacity = String(p));
    apply(progress.get());
    return progress.on(apply); // direct DOM write, zero re-renders
  }, [progress]);
  return <div ref={ref}>I fade in without re-rendering</div>;
}

Returns: ProgressValue (get() / set(n) / on(fn) => unsubscribe). useSceneProgressValue() throws outside a <Scene>.


useElementProgress(ref, { offset }) / useElementProgressValue(ref, { offset })

Element-relative scroll progress without pinning. Maps an element's viewport entry/exit to 01 using Motion-style offset pairs (e.g. ["start end", "end start"]), gated behind an IntersectionObserver. useElementProgress returns a re-rendering number; useElementProgressValue returns a ProgressValue.

import { useRef } from "react";
import { useElementProgress } from "react-kino";

function FadeOnView() {
  const ref = useRef<HTMLDivElement>(null);
  const progress = useElementProgress(ref); // no <Scene> required
  return <div ref={ref} style={{ opacity: progress }}>Tied to my own position</div>;
}

Offset edges accept "start" / "center" / "end", a fraction (01), or a percentage string. Default: ["start end", "end start"].


useSceneContext()

Access the numeric progress value from a parent <Scene>. Useful for building custom components that react to scene progress. Re-renders the calling component each frame (the backward-compatible path); prefer useSceneProgressValue() for hot animations.

import { useSceneContext } from "react-kino";

function CustomFadeIn() {
  const { progress } = useSceneContext();
  return <div style={{ opacity: progress }}>I fade in as you scroll</div>;
}

Returns: { progress: number } -- throws if used outside a <Scene>.


useSceneContextOptional()

Non-throwing variant of useSceneContext(). Returns null instead of throwing when used outside a <Scene>, so components can branch on the result (e.g. render a fallback, or work both inside and outside a scene) without wrapping the hook call in try/catch.

import { useSceneContextOptional } from "react-kino";

function OptionalFadeIn() {
  const scene = useSceneContextOptional();
  const progress = scene?.progress ?? 1;
  return <div style={{ opacity: progress }}>Fades in if inside a Scene</div>;
}

Returns: { progress: number } | null.


useKino()

Access the root ScrollTracker instance from <Kino>. For advanced use cases where you need direct access to the scroll engine.

import { useKino } from "react-kino";

function AdvancedComponent() {
  const { tracker } = useKino();
  // tracker.subscribe(), tracker.start(), tracker.stop()
}

Returns: { tracker: ScrollTracker } -- throws if used outside <Kino>.


useKinoOptional()

Non-throwing variant of useKino(). Returns null instead of throwing when used outside a <Kino> provider.

Returns: { tracker: ScrollTracker } | null.


useIsClient()

SSR guard. Returns false on the server and during hydration, true after the component mounts on the client.

import { useIsClient } from "react-kino";

function SafeComponent() {
  const isClient = useIsClient();
  if (!isClient) return <div>Loading...</div>;
  return <div>Window width: {window.innerWidth}</div>;
}

Returns: boolean


TypeScript

react-kino is written in TypeScript and ships full .d.ts declarations plus source maps.

Prop types: every component's props are exported, so you can type wrapper components, forward refs, or build your own presets without re-declaring shapes:

import type { SceneProps, RevealProps, CompareSliderProps } from "react-kino";

Exported prop types: SceneProps, RevealProps, CounterProps, ParallaxProps, CompareSliderProps, ProgressProps, VideoScrollProps, TextRevealProps, HorizontalScrollProps, PanelProps, MarqueeProps, StickyHeaderProps, ScrollTransformProps, and the TransformState shape used by ScrollTransform.

Easing types: the built-in easing preset names are typed and re-exported from @react-kino/core, so easing="ease-out-cubic" autocompletes and rejects typos:

import type { EasingName, EasingFn, ProgressData } from "react-kino";

// EasingName = "linear" | "ease-in" | "ease-out" | "ease-in-out"
//            | "ease-in-cubic" | "ease-out-cubic" | "ease-in-out-cubic"
//            | "ease-in-quart" | "ease-out-quart" | "ease-in-out-quart"

const myEasing: EasingName = "ease-out-cubic";

EasingFn ((t: number) => number) and ProgressData (the shape emitted by the scroll engine) are also re-exported for advanced use with @react-kino/core directly.


Recipes

Apple-style product hero with parallax

import { Kino, Scene, Reveal, Parallax } from "react-kino";

function ProductHero() {
  return (
    <Kino>
      <Scene duration="400vh">
        {(progress) => (
          <div style={{ position: "relative", height: "100vh", overflow: "hidden" }}>
            <Parallax speed={0.3}>
              <img
                src="/product-hero.jpg"
                alt=""
                style={{
                  width: "100%",
                  height: "140vh",
                  objectFit: "cover",
                  transform: `scale(${1 + progress * 0.1})`,
                }}
              />
            </Parallax>

            <div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
              <Reveal animation="fade-up" at={0.1}>
                <h1 style={{ fontSize: "5rem", color: "#fff" }}>iPhone 20</h1>
              </Reveal>

              <Reveal animation="fade" at={0.3}>
                <p style={{ fontSize: "1.5rem", color: "rgba(255,255,255,0.8)" }}>
                  The future in your hands.
                </p>
              </Reveal>
            </div>
          </div>
        )}
      </Scene>
    </Kino>
  );
}

Stat counter section

import { Kino, Scene, Reveal, Counter } from "react-kino";

function Stats() {
  return (
    <Kino>
      <Scene duration="250vh">
        <div style={{ display: "flex", gap: "4rem", justifyContent: "center", alignItems: "center", height: "100vh" }}>
          <Reveal animation="fade-up" at={0.1}>
            <div style={{ textAlign: "center" }}>
              <Counter from={0} to={50} at={0.15} span={0.4} className="stat-number" />
              <p>Countries</p>
            </div>
          </Reveal>

          <Reveal animation="fade-up" at={0.2}>
            <div style={{ textAlign: "center" }}>
              <Counter from={0} to={10000000} at={0.25} span={0.4} className="stat-number" />
              <p>Users</p>
            </div>
          </Reveal>

          <Reveal animation="fade-up" at={0.3}>
            <div style={{ textAlign: "center" }}>
              <Counter from={0} to={99.9} at={0.35} span={0.4} format={(n) => `${n.toFixed(1)}%`} />
              <p>Uptime</p>
            </div>
          </Reveal>
        </div>
      </Scene>
    </Kino>
  );
}

Device tilt (3D perspective)

import { Kino, Scene, ScrollTransform } from "react-kino";

function DeviceTilt() {
  return (
    <Kino>
      <Scene duration="350vh">
        <ScrollTransform
          from={{ rotateX: 40, rotateY: -12, scale: 0.82, opacity: 0.3 }}
          to={{ rotateX: 0, rotateY: 0, scale: 1, opacity: 1 }}
          perspective={1200}
          span={0.5}
          easing="ease-out-cubic"
          transformOrigin="center bottom"
        >
          <img src="/device.png" alt="Product" style={{ width: "100%", borderRadius: "20px" }} />
        </ScrollTransform>
      </Scene>
    </Kino>
  );
}

Before/after comparison

import { Kino, Scene, CompareSlider } from "react-kino";

function BeforeAfter() {
  return (
    <Kino>
      <Scene duration="300vh">
        <div style={{ height: "100vh", display: "grid", placeItems: "center" }}>
          <CompareSlider
            scrollDriven
            before={
              <img src="/old-design.png" alt="Before"
                style={{ width: "100%", height: "100%", objectFit: "cover" }} />
            }
            after={
              <img src="/new-design.png" alt="After"
                style={{ width: "100%", height: "100%", objectFit: "cover" }} />
            }
          />
        </div>
      </Scene>
    </Kino>
  );
}

Horizontal feature showcase

import { Kino, HorizontalScroll, Panel } from "react-kino";

function FeatureShowcase() {
  const features = [
    { title: "Fast", description: "Sub-3KB scroll engine", bg: "#0a0a0a" },
    { title: "Declarative", description: "Compose like React components", bg: "#111" },
    { title: "Accessible", description: "Respects prefers-reduced-motion", bg: "#1a1a1a" },
    { title: "Universal", description: "SSR + Next.js App Router ready", bg: "#222" },
  ];

  return (
    <Kino>
      <HorizontalScroll>
        {features.map((f) => (
          <Panel key={f.title}>
            <div style={{
              background: f.bg,
              color: "#fff",
              height: "100%",
              display: "grid",
              placeItems: "center",
            }}>
              <div style={{ textAlign: "center" }}>
                <h2 style={{ fontSize: "3rem" }}>{f.title}</h2>
                <p style={{ opacity: 0.7 }}>{f.description}</p>
              </div>
            </div>
          </Panel>
        ))}
      </HorizontalScroll>
    </Kino>
  );
}

shadcn Registry

Install a thin wrapper component directly into your project using the shadcn CLI:

npx shadcn add https://react-kino.dev/registry/components/scene.json

Each wrapper re-exports from react-kino, so install the package as well (recommended):

npm install react-kino

SSR / Next.js

react-kino is SSR-safe and defers scroll logic to useEffect.

Next.js App Router: Use react-kino inside a client component boundary ("use client").

// app/page.tsx
"use client";
import { Kino, Scene, Reveal } from "react-kino";

export default function Page() {
  return (
    <Kino>
      <Scene duration="200vh">
        <Reveal animation="fade-up">
          <h1>Works with App Router</h1>
        </Reveal>
      </Scene>
    </Kino>
  );
}

What happens on the server: Components render their children immediately with no animation styles. Scroll tracking starts after hydration on the client.


Accessibility

react-kino respects the prefers-reduced-motion media query:

  • <Reveal> -- content renders immediately in its visible state, no animation
  • <Parallax> -- parallax offset is disabled, content scrolls normally
  • <ScrollTransform> -- jumps to the to state immediately, no interpolation
  • <Counter> -- displays the final to value immediately once progress reaches at
  • <Marquee> -- renders items in a static flex layout instead of animating
  • <StickyHeader> -- transitions are disabled, background changes immediately
  • <VideoScroll> -- stays on the poster frame instead of scrubbing
  • <TextReveal> -- all text renders immediately at full opacity
  • <HorizontalScroll> -- panels render without the scroll-linked horizontal transform, instead of scroll-jacking sideways

No additional configuration is required. This behavior is automatic.

Keyboard & screen reader support: <CompareSlider> exposes its drag handle as a fully accessible slider -- role="slider" with aria-valuenow/aria-valuemin/aria-valuemax, keyboard-operable via ArrowLeft/ArrowRight (nudge by 5%) and Home/End (jump to the ends), labeled via the ariaLabel prop, and excluded from the tab order in scrollDriven mode (where its position isn't user-adjustable).


Performance

The ref-based engine

react-kino writes to the DOM imperatively on the scroll hot path — the same approach as Motion's MotionValues and GSAP's ScrollTrigger — instead of calling setState every frame.

  • ProgressValue, not setState -- <Scene> exposes progress as a stable ProgressValue from @react-kino/core. The built-in components (<Parallax>, <ScrollTransform>, <Reveal>, <HorizontalScroll>, <Counter>, <TextReveal>, <VideoScroll>) subscribe and write transform / opacity / textContent directly to their own elements. Scrolling a scene triggers zero React re-renders for them.
  • Backward-compatible dual path -- the numeric useSceneContext() and render-prop (<Scene>{(p) => …}</Scene>) APIs are unchanged; they just opt back into re-rendering. Use useSceneProgressValue() / useScrollProgressValue() to get the fast path in your own components.
  • IntersectionObserver gating -- scenes only do per-frame work while near the viewport (generous 100% rootMargin); off-screen scenes cost nothing, and progress snaps to its exact value on fast re-entry rather than a stale one.
  • Element-relative offsets -- useElementProgress and <Reveal trigger="visibility"> tie animation to an element's own position without pinning, via pure offset math in the core.

Under the hood

  • Passive scroll listeners -- all scroll event listeners use { passive: true }
  • requestAnimationFrame batching -- scroll updates are batched via RAF to avoid layout thrashing
  • Debounced resize handling -- window resize events (including mobile URL-bar show/hide) are debounced and coalesced into a single rAF-scheduled recompute, so <Scene>, <VideoScroll>, and <HorizontalScroll> never desync
  • GPU-accelerated transforms -- parallax and reveal animations use transform and opacity (composite-only properties)
  • will-change hints -- applied to animating elements for browser optimization
  • Sub-1 KB core -- @react-kino/core contains all scroll math with zero dependencies
  • Tree-shakeable -- import only the components you use; unused code is eliminated at build time
  • Debuggable -- source maps are shipped for both react-kino and @react-kino/core

Browser Support

Feature Chrome Firefox Safari Edge
Core scroll tracking 64+ 60+ 13+ 79+
position: sticky 56+ 59+ 13+ 79+
prefers-reduced-motion 74+ 63+ 10.1+ 79+

Contributing

Contributions are welcome. Please open an issue first to discuss what you would like to change.

git clone https://github.com/btahir/react-kino.git
cd react-kino
pnpm install
pnpm dev