feat: replace CSS hero motif with a real WebGL 3D braided-crown scene

Adds Hero3DScene, a procedurally-built rotating braid mesh rendered via
@react-three/fiber, replacing the flat CSS-circle Hero3D component. Falls
back to a static gradient circle under prefers-reduced-motion, matching the
old component's fallback behavior.

Also adds a ResizeObserver mock to the test setup, required by
@react-three/fiber's Canvas (via react-use-measure) in jsdom, which doesn't
implement it natively.
This commit is contained in:
belviskhoremk
2026-08-03 22:49:24 +00:00
parent 6244ef11ce
commit 12beaa990e
7 changed files with 154 additions and 73 deletions

View File

@@ -1,37 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
describe('Hero3D', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => vi.restoreAllMocks())
it('renders the motif container', async () => {
vi.doMock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion')
return {
...actual,
useReducedMotion: () => false,
}
})
const { default: Hero3D } = await import('./Hero3D')
render(<Hero3D />)
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
})
it('renders statically when reduced motion is preferred', async () => {
vi.doMock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion')
return {
...actual,
useReducedMotion: () => true,
}
})
const { default: Hero3D } = await import('./Hero3D')
render(<Hero3D />)
expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
expect(screen.getByTestId('hero-3d-motif').className).toMatch(/static/)
})
})

View File

@@ -1,25 +0,0 @@
import { motion, useReducedMotion, useScroll, useTransform } from 'framer-motion'
import styles from './Hero3D.module.css'
function Hero3D() {
const prefersReducedMotion = useReducedMotion()
const { scrollY } = useScroll()
const rotateY = useTransform(scrollY, [0, 600], [0, 180])
const rotateX = useTransform(scrollY, [0, 600], [0, 20])
if (prefersReducedMotion) {
return (
<div className={styles.wrapper} data-testid="hero-3d">
<div className={styles.static} data-testid="hero-3d-motif" />
</div>
)
}
return (
<div className={styles.wrapper} data-testid="hero-3d">
<motion.div className={styles.motif} style={{ rotateY, rotateX }} />
</div>
)
}
export default Hero3D

View File

@@ -1,18 +1,16 @@
.wrapper {
perspective: 900px;
width: 220px;
height: 220px;
width: 280px;
height: 280px;
margin: 0 auto;
}
.motif {
.wrapper canvas {
outline: none;
}
.fallbackMotif {
width: 100%;
height: 100%;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, #e6c98f, var(--color-gold));
transform-style: preserve-3d;
}
.static {
composes: motif;
}

View File

@@ -0,0 +1,44 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, afterEach } from 'vitest'
vi.mock('@react-three/fiber', () => ({
Canvas: ({ children }: { children?: React.ReactNode }) => <div data-testid="r3f-canvas">{children}</div>,
useFrame: () => {},
}))
describe('Hero3DScene', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the static fallback and no canvas when reduced motion is preferred', async () => {
vi.resetModules()
vi.doMock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion')
return {
...actual,
useReducedMotion: () => true,
}
})
const { default: Hero3DScene } = await import('./Hero3DScene')
render(<Hero3DScene />)
const wrapper = screen.getByTestId('hero-3d-scene')
expect(wrapper.className).toMatch(/static/)
expect(screen.queryByTestId('r3f-canvas')).not.toBeInTheDocument()
})
it('renders the WebGL canvas when motion is allowed', async () => {
vi.resetModules()
vi.doMock('framer-motion', async () => {
const actual = await vi.importActual('framer-motion')
return {
...actual,
useReducedMotion: () => false,
}
})
const { default: Hero3DScene } = await import('./Hero3DScene')
render(<Hero3DScene />)
expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument()
expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,92 @@
import { useMemo, useRef } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import { useReducedMotion, useScroll, useTransform, type MotionValue } from 'framer-motion'
import styles from './Hero3DScene.module.css'
const GOLD = '#B08D57'
const STRAND_COUNT = 6
interface BraidStrandProps {
radius: number
angleOffset: number
}
function BraidStrand({ radius, angleOffset }: BraidStrandProps) {
const geometry = useMemo(() => {
const points: THREE.Vector3[] = []
const segments = 20
for (let i = 0; i <= segments; i++) {
const t = i / segments
const angle = angleOffset + t * Math.PI * 0.6
const r = radius * (1 - t * 0.3)
points.push(new THREE.Vector3(Math.cos(angle) * r, t * 1.6 - 0.8, Math.sin(angle) * r))
}
const curve = new THREE.CatmullRomCurve3(points)
return new THREE.TubeGeometry(curve, 40, 0.05, 8, false)
}, [radius, angleOffset])
return (
<mesh geometry={geometry}>
<meshStandardMaterial color={GOLD} metalness={0.6} roughness={0.35} />
</mesh>
)
}
interface BraidedCrownProps {
scrollTilt: MotionValue<number>
}
function BraidedCrown({ scrollTilt }: BraidedCrownProps) {
const groupRef = useRef<THREE.Group>(null)
useFrame((_state, delta) => {
if (!groupRef.current) return
groupRef.current.rotation.y += delta * 0.25
groupRef.current.rotation.x = scrollTilt.get()
})
const strands = useMemo(
() =>
Array.from({ length: STRAND_COUNT }, (_, i) => ({
radius: 1.1,
angleOffset: (i / STRAND_COUNT) * Math.PI * 2,
})),
[],
)
return (
<group ref={groupRef}>
{strands.map((s, i) => (
<BraidStrand key={i} radius={s.radius} angleOffset={s.angleOffset} />
))}
</group>
)
}
function Hero3DScene() {
const prefersReducedMotion = useReducedMotion()
const { scrollY } = useScroll()
const scrollTilt = useTransform(scrollY, [0, 600], [0, 0.35])
if (prefersReducedMotion) {
return (
<div className={`${styles.wrapper} static`} data-testid="hero-3d-scene">
<div className={styles.fallbackMotif} />
</div>
)
}
return (
<div className={styles.wrapper} data-testid="hero-3d-scene" aria-hidden="true">
<Canvas camera={{ position: [0, 0, 4], fov: 45 }}>
<ambientLight intensity={0.4} />
<directionalLight position={[2, 3, 4]} intensity={1.1} />
<directionalLight position={[-2, -1, -3]} intensity={0.3} />
<BraidedCrown scrollTilt={scrollTilt} />
</Canvas>
</div>
)
}
export default Hero3DScene

View File

@@ -1,5 +1,5 @@
import { Link, useNavigate } from 'react-router-dom'
import Hero3D from '../components/Hero3D'
import Hero3DScene from '../components/Hero3DScene'
import ScrollReveal from '../components/ScrollReveal'
import ServiceCard from '../components/ServiceCard'
import { services } from '../data/services'
@@ -18,7 +18,7 @@ function Home() {
return (
<div>
<section className={styles.hero}>
<Hero3D />
<Hero3DScene />
<h1 className={styles.headline}>Your hair, your crown</h1>
<p className={styles.tagline}>Protective Styles by Mira</p>
<div className={styles.ctas}>

View File

@@ -10,6 +10,15 @@ class MockIntersectionObserver {
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver
// Mock ResizeObserver (required by @react-three/fiber's Canvas, via react-use-measure)
class MockResizeObserver {
observe = vi.fn()
unobserve = vi.fn()
disconnect = vi.fn()
}
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver
// Setup matchMedia mock for framer-motion's useReducedMotion
// Make it writable so tests can override it
Object.defineProperty(window, 'matchMedia', {