chore: install openagent opencode
Signed-off-by: Dmytro Stanchiev <git@dmytros.dev>
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
<!-- Context: ui/scrollytelling-headphone | Priority: high | Version: 1.0 | Updated: 2026-02-15 -->
|
||||
|
||||
---
|
||||
description: "Full Next.js implementation of scroll-linked image sequence animation"
|
||||
---
|
||||
|
||||
# Example: Scrollytelling Headphone Animation
|
||||
|
||||
**Purpose**: Full Next.js implementation of scroll-linked image sequence animation
|
||||
|
||||
**Last Updated**: 2026-01-07
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Complete working example of "Zenith X" headphone scrollytelling page using Next.js 14, Framer Motion, and Canvas.
|
||||
|
||||
**Tech Stack**: Next.js 14 (App Router) + Framer Motion + Canvas + Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── page.tsx
|
||||
├── components/
|
||||
│ └── HeadphoneScroll.tsx
|
||||
└── globals.css
|
||||
public/
|
||||
└── frames/
|
||||
└── frame_0001.webp through frame_0120.webp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. globals.css
|
||||
|
||||
```css
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-[#050505] text-white antialiased;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. app/page.tsx
|
||||
|
||||
```tsx
|
||||
import HeadphoneScroll from './components/HeadphoneScroll'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="bg-[#050505]">
|
||||
<HeadphoneScroll />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. components/HeadphoneScroll.tsx
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
|
||||
const FRAME_COUNT = 120
|
||||
|
||||
export default function HeadphoneScroll() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [images, setImages] = useState<HTMLImageElement[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Track scroll progress (0 to 1)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end end']
|
||||
})
|
||||
|
||||
// Map scroll progress to frame index
|
||||
const frameIndex = useTransform(scrollYProgress, [0, 1], [0, FRAME_COUNT - 1])
|
||||
const [currentFrame, setCurrentFrame] = useState(0)
|
||||
|
||||
// Update current frame
|
||||
useEffect(() => {
|
||||
return frameIndex.on('change', (latest) => {
|
||||
setCurrentFrame(Math.round(latest))
|
||||
})
|
||||
}, [frameIndex])
|
||||
|
||||
// Preload all images
|
||||
useEffect(() => {
|
||||
const loadImages = async () => {
|
||||
const promises = Array.from({ length: FRAME_COUNT }, (_, i) => {
|
||||
return new Promise<HTMLImageElement>((resolve) => {
|
||||
const img = new Image()
|
||||
const frameNum = String(i + 1).padStart(4, '0')
|
||||
img.src = `/frames/frame_${frameNum}.webp`
|
||||
img.onload = () => resolve(img)
|
||||
})
|
||||
})
|
||||
|
||||
const loaded = await Promise.all(promises)
|
||||
setImages(loaded)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadImages()
|
||||
}, [])
|
||||
|
||||
// Render current frame to canvas
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !images.length) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const img = images[currentFrame]
|
||||
|
||||
// Set canvas size
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
|
||||
// Clear and draw centered
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
const scale = Math.min(
|
||||
canvas.width / img.width,
|
||||
canvas.height / img.height
|
||||
)
|
||||
|
||||
const x = (canvas.width - img.width * scale) / 2
|
||||
const y = (canvas.height - img.height * scale) / 2
|
||||
|
||||
ctx.drawImage(img, x, y, img.width * scale, img.height * scale)
|
||||
}, [currentFrame, images])
|
||||
|
||||
// Text overlay opacity transforms
|
||||
const title = useTransform(scrollYProgress, [0, 0.1, 0.2], [1, 1, 0])
|
||||
const text1 = useTransform(scrollYProgress, [0.25, 0.3, 0.4], [0, 1, 0])
|
||||
const text2 = useTransform(scrollYProgress, [0.55, 0.6, 0.7], [0, 1, 0])
|
||||
const cta = useTransform(scrollYProgress, [0.85, 0.9, 1], [0, 1, 1])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-[#050505]">
|
||||
<div className="h-12 w-12 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-[400vh]">
|
||||
{/* Sticky Canvas */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="sticky top-0 h-screen w-full"
|
||||
style={{ willChange: 'transform' }}
|
||||
/>
|
||||
|
||||
{/* Text Overlays */}
|
||||
<motion.div
|
||||
style={{ opacity: title }}
|
||||
className="pointer-events-none fixed inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h1 className="text-7xl font-bold tracking-tight text-white/90">
|
||||
Zenith X
|
||||
</h1>
|
||||
<p className="mt-4 text-xl text-white/60">Pure Sound.</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ opacity: text1 }}
|
||||
className="pointer-events-none fixed inset-y-0 left-20 flex items-center"
|
||||
>
|
||||
<p className="text-4xl font-bold tracking-tight text-white/90">
|
||||
Precision Engineering.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ opacity: text2 }}
|
||||
className="pointer-events-none fixed inset-y-0 right-20 flex items-center"
|
||||
>
|
||||
<p className="text-4xl font-bold tracking-tight text-white/90">
|
||||
Titanium Drivers.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ opacity: cta }}
|
||||
className="pointer-events-none fixed inset-0 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-6xl font-bold tracking-tight text-white/90">
|
||||
Hear Everything.
|
||||
</h2>
|
||||
<button className="pointer-events-auto mt-8 rounded-full bg-white px-8 py-3 text-lg font-semibold text-black transition hover:bg-white/90">
|
||||
Pre-Order Now
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
**Line 15-18**: `useScroll` tracks scroll progress from container start to end
|
||||
**Line 21**: `useTransform` maps 0-1 scroll to 0-119 frame index
|
||||
**Line 32-46**: Preload all 120 frames using Promise.all
|
||||
**Line 49-70**: Draw current frame to canvas, scaled and centered
|
||||
**Line 73-76**: Text opacity transforms for fade in/out at specific scroll positions
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Install dependencies: `bun --bun install framer-motion`
|
||||
2. Place 120 WebP frames in `/public/frames/`
|
||||
3. Copy code into respective files
|
||||
4. Run: `bun --bun run dev`
|
||||
|
||||
---
|
||||
|
||||
## Customization
|
||||
|
||||
**Change frame count**: Update `FRAME_COUNT` constant (line 7)
|
||||
**Adjust scroll length**: Change `h-[400vh]` to `h-[300vh]` or `h-[500vh]` (line 120)
|
||||
**Modify text timing**: Update transform ranges in lines 73-76
|
||||
**Change colors**: Update `bg-[#050505]` to match your image background
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- concepts/scroll-linked-animations.md - Understanding the technique
|
||||
- guides/scrollytelling-setup.md - Getting started
|
||||
- lookup/scroll-animation-prompts.md - Generating image sequences
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Framer Motion Docs](https://www.framer.com/motion/)
|
||||
- [Next.js App Router](https://nextjs.org/docs/app)
|
||||
Reference in New Issue
Block a user