OpenWriterControle/.opencode/context/ui/web/design/examples/scrollytelling-headphone.md
Darren Hinde 9a58a03284
fix(context): Add frontmatter to context files with missing metadata (#97)
* feat(context): Add project intelligence system

- Add project-intelligence.md standard (what & why)
- Add project-intelligence-management.md guide (how to manage)
- Add project-intelligence/ folder with 6 context files:
  - navigation.md (quick overview & routes)
  - business-domain.md (business context, users, value)
  - technical-domain.md (stack, architecture, decisions)
  - business-tech-bridge.md (business → technical mapping)
  - decisions-log.md (decision history with context)
  - living-notes.md (active issues, debt, questions)
- Update standards navigation with new entries
- Register all new contexts in registry.json

This enables projects to document and maintain business + technical
context for quick onboarding and lasting project understanding.

* fix(registry): Add project-intelligence to profiles

- Add context:project-intelligence/* to developer, business, full, and advanced profiles
- Ensures project intelligence contexts are included when these profiles are selected

This complements commit c731328 which added the context entries.

* docs(core): refine project intelligence system and deprecate legacy context

* feat(claude): simplify Claude Code integration with unified installer

- Create single install-claude.sh script that auto-converts and installs
- Remove redundant bootstrap-install.sh and install.sh scripts
- Delete stale plugin/ directory (now auto-generated from .opencode/)
- Add Claude Code documentation (agent-skills, create-subagents, hooks, plugins)
- Add installation profiles (essential, developer, business, full, advanced)
- Update README with Claude Code beta feature documentation
- Add component-planning.md workflow context
- Clean up demo files (assets/, src/)
- Update registry with new context entries
- Add gitignore rule for converter/generated/

The new installer:
- Converts agents automatically from .opencode/agent/
- Installs fresh converted agents to ~/.claude/plugins/
- Single command: ./integrations/claude-code/install-claude.sh

* fix: update README to use new install-claude.sh script

The Claude Code integration now uses the unified install-claude.sh script
instead of the removed bootstrap-install.sh and install.sh scripts.

* fix(context): Add frontmatter to context files with missing metadata

- Add YAML frontmatter with description to system-builder-templates
- Add frontmatter to ui/web/design context files
- Fixes validation warnings in validate-component.sh

* fix(registry): Remove orphaned context entries for deleted files

- Remove copywriting-frameworks entry (file moved to content-creation/principles/)
- Remove tone-voice entry (file moved to content-creation/principles/)
2026-01-17 21:29:48 +00:00

6.8 KiB

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

@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

import HeadphoneScroll from './components/HeadphoneScroll'

export default function Home() {
  return (
    <main className="bg-[#050505]">
      <HeadphoneScroll />
    </main>
  )
}

3. components/HeadphoneScroll.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: npm install framer-motion
  2. Place 120 WebP frames in /public/frames/
  3. Copy code into respective files
  4. Run: npm 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


  • concepts/scroll-linked-animations.md - Understanding the technique
  • guides/scrollytelling-setup.md - Getting started
  • lookup/scroll-animation-prompts.md - Generating image sequences

References