Cinematic scroll-driven storytelling for React.
Core scroll engine under 1 KB gzipped.
- Tiny -- the core scroll engine is under 1 KB gzipped. GSAP ScrollTrigger alone is 33 KB.
- Declarative -- compose
<Scene>,<Reveal>,<ScrollTransform>,<Parallax>,<Counter>,<StickyHeader>,<Marquee>, and<TextReveal>like regular React components. No imperative timelines. - Lightweight runtime --
react-kinouses 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.
Requirements: React 18+
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.
Skip the setup -- scaffold a complete scroll page from a template:
✦ 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.
@react-kino/templates ships three full-page scroll experiences you can drop in and customize:
npm install @react-kino/templatesimport { 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 |
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 |
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.
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 |
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).
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) |
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.
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 |
className |
string |
-- | CSS class for the container |
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.
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 |
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. prefers-reduced-motion: video stays on the poster frame.
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 |
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 |
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.
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).
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.
Access the progress value from a parent <Scene>. Useful for building custom components that react to scene progress.
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>.
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>.
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
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>
);
}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>
);
}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>
);
}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>
);
}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>
);
}Install a thin wrapper component directly into your project using the shadcn CLI:
npx shadcn add https://react-kino.dev/registry/components/scene.jsonEach wrapper re-exports from react-kino, so install the package as well (recommended):
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.
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 thetostate immediately, no interpolation<Counter>-- displays the finaltovalue immediately once progress reachesat<Marquee>-- renders items in a static flex layout instead of animating<StickyHeader>-- transitions are disabled, background changes immediately
No additional configuration is required. This behavior is automatic.
- Passive scroll listeners -- all scroll event listeners use
{ passive: true } - requestAnimationFrame batching -- scroll updates are batched via RAF to avoid layout thrashing
- GPU-accelerated transforms -- parallax and reveal animations use
transformandopacity(composite-only properties) will-changehints -- applied to animating elements for browser optimization- Sub-1 KB core --
@react-kino/corecontains all scroll math with zero dependencies - Tree-shakeable -- import only the components you use; unused code is eliminated at build time
| 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+ |
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






