// ──────────────────────────────────────────────────────────────── // 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 (
{/* ユーザー座標系でのグラデーション(デモの gradientUnits="userSpaceOnUse" 相当) */} {BACKDROP_MORPH_FRAMES.flowerRound.map((shape, i) => ( { pathRefs.current[i] = el; }} d={shape} fill="none" stroke={`url(#${gradId})`} strokeWidth="1.1" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" opacity={0.72 + i * 0.05} style={{ strokeWidth: 2, filter: i === 0 ? `drop-shadow(0 0 10px ${theme.accent}55)` : 'none', }} /> ))}
); }; // ── MorphLineMotif ──────────────────────────────────────────── // Smaller section ornament that uses the same morphing line language as ShapeBackdrop. const MorphLineMotif = ({ size = 220, top, right, bottom, left, opacity = 0.24, strokeWidth = 1.25, delay = 0, rotateDuration = 42, reverse = false, color, }) => { const { theme } = useTheme(); const pathRefs = React.useRef([]); const rotateRef = React.useRef(null); const gradId = React.useMemo(() => `mlm-${Math.random().toString(36).slice(2, 8)}`, []); const strokeColor = color || theme.accent; 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: reverse ? -360 : 360, svgOrigin: '50 50', duration: rotateDuration, 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.2, delay }); [ 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 ? 2.9 : 2.1, stagger: returnsToFlower ? 0 : 0.035, ease: returnsToFlower ? 'sine.inOut' : 'power3.inOut', }); }); }); return () => ctx.revert(); }, [delay, reverse, rotateDuration]); const posStyle = {}; if (top !== undefined) posStyle.top = top; if (right !== undefined) posStyle.right = right; if (bottom !== undefined) posStyle.bottom = bottom; if (left !== undefined) posStyle.left = left; return (
{BACKDROP_MORPH_FRAMES.flowerRound.map((shape, i) => ( { pathRefs.current[i] = el; }} d={shape} fill="none" stroke={`url(#${gradId})`} strokeWidth={strokeWidth} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" opacity={0.62 + i * 0.08} /> ))}
); }; // ── ScrollProgressBar ──────────────────────────────────────── const ScrollProgressBar = () => { const p = useScrollProgress(); const { theme } = useTheme(); return (
); }; // ── ShapeAccent ────────────────────────────────────────────── // セクション装飾用の小さな動くシェイプ const ShapeAccent = ({ size = 120, top = 40, right = 40, bottom, left, color, opacity = 0.55 }) => { const { theme } = useTheme(); const ref = React.useRef(null); const pathRef = React.useRef(null); const fill = color || theme.accent; React.useEffect(() => { if (!window.gsap || !window.MorphSVGPlugin) return; const el = pathRef.current; if (!el) return; const ctx = window.gsap.context(() => { createMorphTimeline(el, { duration: 5, ease: 'sine.inOut' }); if (ref.current) { window.gsap.to(ref.current, { rotation: 360, transformOrigin: '50% 50%', duration: 50, repeat: -1, ease: 'none', }); } }); return () => ctx.revert(); }, []); const posStyle = {}; if (top !== undefined) posStyle.top = top; if (right !== undefined) posStyle.right = right; if (bottom !== undefined) posStyle.bottom = bottom; if (left !== undefined) posStyle.left = left; return (
); }; // ── DecorativeMotionField ───────────────────────────────────── // Section/page ornament: animated score lines plus soft filled marks. const DecorativeMotionField = ({ density = 8, intensity = 0.55, variant = 'score', position = 'absolute' }) => { const { theme } = useTheme(); const uid = React.useMemo(() => `dmf-${Math.random().toString(36).slice(2, 8)}`, []); const lines = Array.from({ length: density }); const petals = Array.from({ length: Math.max(2, Math.round(density / 3)) }); return (
{lines.map((_, i) => { const y = 70 + i * (620 / Math.max(1, density - 1)); const bend = variant === 'crest' ? 110 : 70; return ( ); })} {petals.map((_, i) => ( ))}
); }; // ── useGsapPageEnter ───────────────────────────────────────── const useGsapPageEnter = (ref) => { React.useEffect(() => { const el = ref.current; if (!el || !window.gsap) return; const ctx = window.gsap.context(() => { window.gsap.from(el, { opacity: 0, y: 20, duration: 0.8, ease: 'power3.out', }); }); return () => ctx.revert(); }, [ref]); }; // ── MotionReady ────────────────────────────────────────────── const MotionReady = () => { React.useEffect(() => { if (!window.ScrollTrigger) return; if (document.fonts && document.fonts.ready) { document.fonts.ready.then(() => window.ScrollTrigger.refresh()); } else { setTimeout(() => window.ScrollTrigger.refresh(), 500); } }, []); return null; }; // ── LogoMorph ──────────────────────────────────────────────── // ヒーロー用: 円 → Culina ロゴ(4つのパーツ)へ const LOGO_SHAPES = { // 1. 大きな円 circle: 'M 100,300 C 100,189.54 189.54,100 300,100 C 410.46,100 500,189.54 500,300 C 500,410.46 410.46,500 300,500 C 189.54,500 100,410.46 100,300 Z', // 2. 角丸正方形 rounded: 'M 140,100 L 460,100 C 482.09,100 500,117.91 500,140 L 500,460 C 500,482.09 482.09,500 460,500 L 140,500 C 117.91,500 100,482.09 100,460 L 100,140 C 100,117.91 117.91,100 140,100 Z', // 3. 十字(四方良しの方角を暗示) cross: 'M 240,100 L 360,100 L 360,240 L 500,240 L 500,360 L 360,360 L 360,500 L 240,500 L 240,360 L 100,360 L 100,240 L 240,240 Z', // 4. Culina ロゴ: 4つの正方形 (2x2グリッド) fourSquares: 'M 100,100 L 275,100 L 275,275 L 100,275 Z M 325,100 L 500,100 L 500,275 L 325,275 Z M 100,325 L 275,325 L 275,500 L 100,500 Z M 325,325 L 500,325 L 500,500 L 325,500 Z', }; const LogoMorph = ({ size = 400, color, stroke = false }) => { const { theme } = useTheme(); const pathRefs = React.useRef([]); const wrapRef = React.useRef(null); const fill = color || theme.accent; React.useEffect(() => { if (!window.gsap || !window.MorphSVGPlugin) { console.warn('[LogoMorph] gsap/MorphSVG not ready'); return; } const paths = pathRefs.current.filter(Boolean); const wrap = wrapRef.current; if (!paths.length || !wrap) return; let ctx; ctx = window.gsap.context(() => { window.gsap.set(paths, { attr: { d: CULINA_MARK_PATHS.circle }, transformOrigin: '50% 50%', }); window.gsap.to(wrap, { rotation: 360, transformOrigin: '50% 50%', duration: 90, repeat: -1, ease: 'none', }); window.gsap.timeline({ repeat: -1, repeatDelay: 1 }) .to(paths, { morphSVG: (i) => ({ shape: CULINA_MARK_PATHS.pieces[i], shapeIndex: 0 }), duration: 2.4, stagger: 0.055, ease: 'power3.inOut', }) .to(paths, { scale: 1.035, duration: 1.4, yoyo: true, repeat: 1, ease: 'sine.inOut', }) .to(paths, { morphSVG: { shape: CULINA_MARK_PATHS.circle, shapeIndex: 0 }, duration: 2.1, stagger: { each: 0.04, from: 'center' }, ease: 'power3.inOut', }, '+=0.15'); }); return () => { if (ctx) ctx.revert(); }; }, []); return (
{CULINA_MARK_PATHS.pieces.map((_, i) => ( { pathRefs.current[i] = el; }} d={CULINA_MARK_PATHS.circle} fill={stroke ? 'none' : fill} stroke={stroke ? fill : 'none'} strokeWidth={stroke ? 1.8 : 0} strokeLinejoin="round" strokeLinecap="round" opacity={stroke ? 0.92 : 1} /> ))}
); }; Object.assign(window, { useReveal, useScrollProgress, useGsapPageEnter, ShapeBackdrop, ShapeAccent, ScrollProgressBar, MotionReady, LogoMorph, LOGO_SHAPES, CULINA_MARK_PATHS, DecorativeMotionField, MorphLineMotif, SHAPES, createMorphTimeline, });