// ──────────────────────────────────────────────────────────────── // motion.jsx — Ver2 アニメーション共通ユーティリティ // // 依存: GSAP 3.13+ (Webflowが2025年4月に全プラグイン無料化) // - gsap (コア) // - ScrollTrigger (スクロール連動) // - MorphSVGPlugin (SVGパスモーフィング・demos.gsap.com/demo/shape-index/ と同じプラグイン) // // 提供: // - useReveal(ref, options) : 要素が画面に入った時にfade+slide // - useScrollProgress() : 0..1 のスクロール進捗 // - ShapeBackdrop : ヒーロー背景で滑らかに変形するSVGブロブ // - ShapeAccent : セクション間に置く装飾シェイプ // - ScrollProgressBar : 画面上部のスクロール進捗バー // - useGsapPageEnter : ページマウント時のフェードイン // - MotionReady : ScrollTrigger.refresh 初期化 // ──────────────────────────────────────────────────────────────── (function registerGsap() { if (typeof window === 'undefined') return; if (window.gsap && !window.__GSAP_REGISTERED__) { try { const plugins = []; if (window.ScrollTrigger) plugins.push(window.ScrollTrigger); if (window.MorphSVGPlugin) plugins.push(window.MorphSVGPlugin); if (window.MotionPathPlugin) plugins.push(window.MotionPathPlugin); if (plugins.length) window.gsap.registerPlugin(...plugins); window.__GSAP_REGISTERED__ = true; window.__GSAP_STATUS__ = { gsap: !!window.gsap, ScrollTrigger: !!window.ScrollTrigger, MorphSVGPlugin: !!window.MorphSVGPlugin, MotionPathPlugin: !!window.MotionPathPlugin, }; if (!window.MorphSVGPlugin) console.warn('[motion] MorphSVGPlugin not loaded'); if (!window.ScrollTrigger) console.warn('[motion] ScrollTrigger not loaded'); } catch (e) { console.warn('[motion] registerPlugin failed:', e); } } })(); // Find the scrolling ancestor for a given element. Returns null (= window) if none found. const findScrollRoot = (el) => { let p = el && el.parentElement; while (p) { if (p.hasAttribute && p.hasAttribute('data-scroll-root')) return p; p = p.parentElement; } return null; }; // ── useReveal ──────────────────────────────────────────────── const useReveal = (ref, options = {}) => { const { y = 80, x = 0, duration = 1.2, delay = 0, stagger = 0.15, start = 'top 80%', once = true, scale = 1 } = options; React.useEffect(() => { const el = ref.current; if (!el || !window.gsap) return; const targets = stagger ? el.children : el; // Fallback: if ScrollTrigger isn't available OR we can't find a container, just show immediately if (!window.ScrollTrigger) return; const scroller = findScrollRoot(el); // null → default (window) const ctx = window.gsap.context(() => { window.gsap.from(targets, { opacity: 0, y, x, scale, duration, delay, stagger, ease: 'power4.out', scrollTrigger: { trigger: el, scroller: scroller || undefined, start, toggleActions: once ? 'play none none none' : 'play reverse play reverse', // Safety: if ScrollTrigger fails to detect visibility, still play once: once, }, }); }); return () => ctx.revert(); }, [ref, y, x, duration, delay, stagger, start, once]); }; // ── useScrollProgress ──────────────────────────────────────── // Listens to BOTH the window and any [data-scroll-root] inner container. // Returns whichever source is actively scrolling. const useScrollProgress = () => { const [p, setP] = React.useState(0); React.useEffect(() => { const computeFromWindow = () => { const h = document.documentElement; const total = (h.scrollHeight - h.clientHeight) || 1; return Math.min(1, Math.max(0, (window.scrollY || 0) / total)); }; const computeFromEl = (el) => { const total = (el.scrollHeight - el.clientHeight) || 1; return Math.min(1, Math.max(0, el.scrollTop / total)); }; const scrollRoot = document.querySelector('[data-scroll-root]'); const onScroll = () => { // Use inner scroll container if it exists and actually scrolls; else window. if (scrollRoot && scrollRoot.scrollHeight > scrollRoot.clientHeight + 1) { setP(computeFromEl(scrollRoot)); } else { setP(computeFromWindow()); } }; window.addEventListener('scroll', onScroll, { passive: true }); if (scrollRoot) scrollRoot.addEventListener('scroll', onScroll, { passive: true }); onScroll(); return () => { window.removeEventListener('scroll', onScroll); if (scrollRoot) scrollRoot.removeEventListener('scroll', onScroll); }; }, []); return p; }; // ── 四方良しをモチーフにした4つのシェイプ(同じ点数でモーフィングが滑らかになる) ── // demos.gsap.com/demo/shape-index/ の #flower パス(4つの円が繋がった花びら形)を基準 // viewBox 0 0 100 100 基準 const SHAPES = [ // 旧互換: ShapeAccent 等で使用 'M 50,15 Q 85,15 85,50 Q 85,85 50,85 Q 15,85 15,50 Q 15,15 50,15 Z', 'M 50,10 Q 80,20 85,50 Q 80,80 50,90 Q 20,80 15,50 Q 20,20 50,10 Z', 'M 50,15 Q 80,15 85,50 Q 80,85 50,85 Q 20,85 15,50 Q 20,15 50,15 Z', 'M 50,8 Q 82,20 85,50 Q 82,80 50,92 Q 18,80 15,50 Q 18,20 50,8 Z', ]; // デモ準拠のシェイプ群(すべて 0..100 viewBox) // 同じ点数なので MorphSVG がスムーズに補間し、shapeIndex 値で始点が変わる const DEMO_SHAPES = { // 花びら/四つ葉(デモの #flower をスケール) flower: 'M 73.87,3.51 C 86.35,3.51 96.48,13.63 96.48,26.13 C 96.48,38.62 86.35,48.75 73.87,48.75 C 72.23,48.75 70.63,48.57 69.09,48.24 L 69.09,52.27 C 70.63,51.94 72.23,51.76 73.87,51.76 C 86.35,51.76 96.48,61.89 96.48,74.38 C 96.48,86.87 86.35,97 73.87,97 C 61.38,97 51.25,86.87 51.25,74.38 C 51.25,72.9 51.4,71.44 51.67,70.04 L 47.82,70.04 C 48.09,71.44 48.24,72.9 48.24,74.38 C 48.24,86.87 38.12,97 25.63,97 C 13.14,97 3.02,86.87 3.02,74.38 C 3.02,61.89 13.14,51.76 25.63,51.76 C 27.18,51.76 28.69,51.92 30.15,52.21 L 30.15,48.29 C 28.69,48.59 27.18,48.75 25.63,48.75 C 13.14,48.75 3.02,38.62 3.02,26.13 C 3.02,13.63 13.14,3.51 25.63,3.51 C 38.12,3.51 48.24,13.63 48.24,26.13 C 48.24,27.68 48.08,29.19 47.79,30.65 L 51.71,30.65 C 51.41,29.19 51.25,27.68 51.25,26.13 C 51.25,13.63 61.38,3.51 73.87,3.51 Z', // 四角(デモの #square) square: 'M 100,96.55 L 0,96.55 L 0,0 L 100,0 L 100,96.55 Z', // 菱形 diamond: 'M 50,5 L 95,50 L 50,95 L 5,50 L 50,5 Z', // 円(シンプル) circle: 'M 95,50 C 95,74.85 74.85,95 50,95 C 25.15,95 5,74.85 5,50 C 5,25.15 25.15,5 50,5 C 74.85,5 95,25.15 95,50 Z', }; // MorphSVGを使ってパスを順番にモーフィングするタイムライン生成 const createMorphTimeline = (pathEl, options = {}) => { const { duration = 4, ease = 'sine.inOut', startIdx = 0 } = options; if (!window.gsap || !window.MorphSVGPlugin) return null; const tl = window.gsap.timeline({ repeat: -1 }); const sequence = []; for (let i = 0; i < SHAPES.length; i++) { sequence.push(SHAPES[(startIdx + i + 1) % SHAPES.length]); } sequence.forEach((shape) => { tl.to(pathEl, { morphSVG: { shape, shapeIndex: 0 }, duration, ease, }); }); return tl; }; // Culina mark approximation as four independent paths. // All four pieces begin as the same circle, so the animation reads as: // one circle -> four-way Culina mark -> one circle. const CULINA_MARK_PATHS = { circle: 'M 95,50 C 95,74.85 74.85,95 50,95 C 25.15,95 5,74.85 5,50 C 5,25.15 25.15,5 50,5 C 74.85,5 95,25.15 95,50 Z', pieces: [ 'M 18,8 L 47,8 C 49,8 50,9 50,11 L 50,35 C 50,43.28 43.28,50 35,50 L 11,50 C 9,50 8,49 8,47 L 8,18 C 8,12.48 12.48,8 18,8 Z', 'M 53,8 L 82,8 C 87.52,8 92,12.48 92,18 L 92,47 C 92,49 91,50 89,50 L 65,50 C 56.72,50 50,43.28 50,35 L 50,11 C 50,9 51,8 53,8 Z', 'M 11,50 L 35,50 C 43.28,50 50,56.72 50,65 L 50,89 C 50,91 49,92 47,92 L 18,92 C 12.48,92 8,87.52 8,82 L 8,53 C 8,51 9,50 11,50 Z', 'M 65,50 L 89,50 C 91,50 92,51 92,53 L 92,82 C 92,87.52 87.52,92 82,92 L 53,92 C 51,92 50,91 50,89 L 50,65 C 50,56.72 56.72,50 65,50 Z', ], }; const BACKDROP_MORPH_FRAMES = { flowerRound: [ DEMO_SHAPES.flower, DEMO_SHAPES.flower, DEMO_SHAPES.flower, DEMO_SHAPES.flower, ], fourSquares: [ 'M 10,10 L 42,10 L 42,42 L 10,42 Z', 'M 58,10 L 90,10 L 90,42 L 58,42 Z', 'M 10,58 L 42,58 L 42,90 L 10,90 Z', 'M 58,58 L 90,58 L 90,90 L 58,90 Z', ], fourPetals: [ 'M 50,50 C 34,50 21,37 21,24 C 21,13 30,5 42,5 C 55,5 64,18 61,31 C 59,41 53,47 50,50 Z', 'M 50,50 C 50,34 63,21 76,21 C 87,21 95,30 95,42 C 95,55 82,64 69,61 C 59,59 53,53 50,50 Z', 'M 50,50 C 66,50 79,63 79,76 C 79,87 70,95 58,95 C 45,95 36,82 39,69 C 41,59 47,53 50,50 Z', 'M 50,50 C 50,66 37,79 24,79 C 13,79 5,70 5,58 C 5,45 18,36 31,39 C 41,41 47,47 50,50 Z', ], oneSquare: [ 'M 8,8 L 92,8 L 92,92 L 8,92 Z', 'M 8,8 L 92,8 L 92,92 L 8,92 Z', 'M 8,8 L 92,8 L 92,92 L 8,92 Z', 'M 8,8 L 92,8 L 92,92 L 8,92 Z', ], oneCircle: [ DEMO_SHAPES.circle, DEMO_SHAPES.circle, DEMO_SHAPES.circle, DEMO_SHAPES.circle, ], }; // ── ShapeBackdrop ──────────────────────────────────────────── // Hero backdrop: rotating morph sequence // flower-round -> four squares -> four petals -> one square -> one circle. const ShapeBackdrop = () => { const { theme } = useTheme(); const pathRefs = React.useRef([]); const rotateRef = React.useRef(null); const gradId = 'culina-stroke-grad'; React.useEffect(() => { if (!window.gsap || !window.MorphSVGPlugin) return; const paths = pathRefs.current.filter(Boolean); if (!paths.length) return; const ctx = window.gsap.context(() => { if (rotateRef.current) { window.gsap.to(rotateRef.current, { rotation: 360, svgOrigin: '50 50', duration: 34, repeat: -1, ease: 'none', }); } window.gsap.set(paths, { attr: { d: DEMO_SHAPES.flower }, transformOrigin: '50% 50%', }); const tl = window.gsap.timeline({ repeat: -1, repeatDelay: 0.25 }); [ BACKDROP_MORPH_FRAMES.fourSquares, BACKDROP_MORPH_FRAMES.fourPetals, BACKDROP_MORPH_FRAMES.oneSquare, BACKDROP_MORPH_FRAMES.oneCircle, BACKDROP_MORPH_FRAMES.flowerRound, ].forEach((frame, frameIndex) => { const returnsToFlower = frame === BACKDROP_MORPH_FRAMES.flowerRound; tl.to(paths, { morphSVG: (i) => ({ shape: frame[i], shapeIndex: returnsToFlower ? 8 : (frameIndex % 2 ? 0 : 5), }), duration: returnsToFlower ? 3.1 : 2.25, stagger: returnsToFlower ? 0 : 0.045, ease: returnsToFlower ? 'sine.inOut' : 'power3.inOut', }) .to(paths, { scale: frameIndex % 2 ? 1.018 : 0.992, duration: 0.75, yoyo: true, repeat: 1, ease: 'sine.inOut', }, '>-0.08'); }); }); return () => ctx.revert(); }, []); return (