diff --git a/src/components/Hero3D.test.tsx b/src/components/Hero3D.test.tsx
deleted file mode 100644
index 3fc6357..0000000
--- a/src/components/Hero3D.test.tsx
+++ /dev/null
@@ -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()
- 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()
- expect(screen.getByTestId('hero-3d')).toBeInTheDocument()
- expect(screen.getByTestId('hero-3d-motif').className).toMatch(/static/)
- })
-})
diff --git a/src/components/Hero3D.tsx b/src/components/Hero3D.tsx
deleted file mode 100644
index e3ec881..0000000
--- a/src/components/Hero3D.tsx
+++ /dev/null
@@ -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 (
-
- )
- }
-
- return (
-
-
-
- )
-}
-
-export default Hero3D
diff --git a/src/components/Hero3D.module.css b/src/components/Hero3DScene.module.css
similarity index 57%
rename from src/components/Hero3D.module.css
rename to src/components/Hero3DScene.module.css
index f060c85..15c6986 100644
--- a/src/components/Hero3D.module.css
+++ b/src/components/Hero3DScene.module.css
@@ -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;
}
diff --git a/src/components/Hero3DScene.test.tsx b/src/components/Hero3DScene.test.tsx
new file mode 100644
index 0000000..43feb55
--- /dev/null
+++ b/src/components/Hero3DScene.test.tsx
@@ -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 }) => {children}
,
+ 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()
+ 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()
+ expect(screen.getByTestId('hero-3d-scene')).toBeInTheDocument()
+ expect(screen.getByTestId('r3f-canvas')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/Hero3DScene.tsx b/src/components/Hero3DScene.tsx
new file mode 100644
index 0000000..258f034
--- /dev/null
+++ b/src/components/Hero3DScene.tsx
@@ -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 (
+
+
+
+ )
+}
+
+interface BraidedCrownProps {
+ scrollTilt: MotionValue
+}
+
+function BraidedCrown({ scrollTilt }: BraidedCrownProps) {
+ const groupRef = useRef(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 (
+
+ {strands.map((s, i) => (
+
+ ))}
+
+ )
+}
+
+function Hero3DScene() {
+ const prefersReducedMotion = useReducedMotion()
+ const { scrollY } = useScroll()
+ const scrollTilt = useTransform(scrollY, [0, 600], [0, 0.35])
+
+ if (prefersReducedMotion) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export default Hero3DScene
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index fea9a47..9082d39 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -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 (
-
+
Your hair, your crown
Protective Styles by Mira
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 5fa1320..c6c744b 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -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', {