-
-
Save kobitoDevelopment/216ce5ae4338e0f719cff6b5891d1e6a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| .container { | |
| height: 300vh; | |
| } | |
| .ornament-container { | |
| position: fixed; | |
| bottom: 0; | |
| left: 0; | |
| width: 100%; | |
| z-index: 100; | |
| } | |
| .ornament-svg { | |
| display: block; | |
| width: 100%; | |
| overflow: visible; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <div class="container"> | |
| <div class="ornament-container"> | |
| <svg | |
| class="ornament-svg" | |
| preserveAspectRatio="none" | |
| width="1440" | |
| height="80" | |
| viewBox="0 0 1440 80" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path class="ornament-path" fill="#E60019" /> | |
| </svg> | |
| </div> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * SVGスクロール連動アニメーション | |
| * | |
| * 仕組み: | |
| * 1. IntersectionObserver で要素が画面内にあるかを監視 | |
| * 2. scroll イベントでスクロール速度を計算 | |
| * 3. スクロール中は速度に応じてoffsetを更新 | |
| * 4. スクロール停止検知後、requestAnimationFrameでbounce効果付きの戻りアニメーション | |
| */ | |
| // アニメーション設定 | |
| const CONFIG = { | |
| maxOffset: 25, // 最大変形量 | |
| returnDuration: 1000, // 戻りアニメーションの時間(ミリ秒) | |
| velocityDivisor: 50, // スクロール速度からoffsetへの変換係数 | |
| scrollEndDelay: 200, // スクロール停止判定までの時間(ミリ秒) | |
| }; | |
| // パスデータ(PC用) | |
| const PATH_BASE = { | |
| width: 1440, | |
| startY: 0.248291, | |
| ctrl1X: 1239.5, | |
| ctrl1Y: 50.1128, | |
| ctrl2X: 990.399, | |
| ctrl2Y: 79.6554, | |
| midX: 720.5, | |
| midY: 79.6555, | |
| ctrl3X: 450.152, | |
| ctrl3Y: 79.6555, | |
| ctrl4X: 200.676, | |
| ctrl4Y: 50.0151, | |
| }; | |
| // 状態管理 | |
| let currentOffset = 0; | |
| let targetOffset = 0; | |
| let lastScrollY = window.scrollY; | |
| let lastScrollTime = performance.now(); | |
| let isInView = true; | |
| let scrollEndTimer = null; | |
| let returnAnimationId = null; | |
| let smoothingAnimationId = null; | |
| // DOM要素 | |
| const pathElement = document.querySelector(".ornament-path"); | |
| const container = document.querySelector(".ornament-container"); | |
| /** | |
| * offsetからSVGパスのd属性を生成 | |
| */ | |
| function generatePath(offset) { | |
| const ctrl1Y = PATH_BASE.ctrl1Y + offset * 0.6; | |
| const ctrl2Y = PATH_BASE.ctrl2Y + offset; | |
| const midY = PATH_BASE.midY + offset; | |
| const ctrl3Y = PATH_BASE.ctrl3Y + offset; | |
| const ctrl4Y = PATH_BASE.ctrl4Y + offset * 0.6; | |
| return `M0 0L${PATH_BASE.width} 0L${PATH_BASE.width} ${PATH_BASE.startY}C${PATH_BASE.ctrl1X} ${ctrl1Y} ${PATH_BASE.ctrl2X} ${ctrl2Y} ${PATH_BASE.midX} ${midY}C${PATH_BASE.ctrl3X} ${ctrl3Y} ${PATH_BASE.ctrl4X} ${ctrl4Y} 0 0Z`; | |
| } | |
| /** | |
| * パスを更新 | |
| */ | |
| function updatePath(offset) { | |
| currentOffset = offset; | |
| pathElement.setAttribute("d", generatePath(offset)); | |
| } | |
| /** | |
| * easeOutBounce イージング関数 | |
| * bounce効果を実現する | |
| */ | |
| function easeOutBounce(x) { | |
| const n1 = 7.5625; | |
| const d1 = 2.75; | |
| if (x < 1 / d1) { | |
| return n1 * x * x; | |
| } else if (x < 2 / d1) { | |
| return n1 * (x -= 1.5 / d1) * x + 0.75; | |
| } else if (x < 2.5 / d1) { | |
| return n1 * (x -= 2.25 / d1) * x + 0.9375; | |
| } else { | |
| return n1 * (x -= 2.625 / d1) * x + 0.984375; | |
| } | |
| } | |
| /** | |
| * 戻りアニメーション | |
| * requestAnimationFrameを使用してbounce効果で元に戻る | |
| */ | |
| function startReturnAnimation() { | |
| if (returnAnimationId) { | |
| cancelAnimationFrame(returnAnimationId); | |
| } | |
| const startOffset = currentOffset; | |
| const startTime = performance.now(); | |
| function animate(currentTime) { | |
| const elapsed = currentTime - startTime; | |
| const progress = Math.min(elapsed / CONFIG.returnDuration, 1); | |
| const easedProgress = easeOutBounce(progress); | |
| const newOffset = startOffset * (1 - easedProgress); | |
| updatePath(newOffset); | |
| if (progress < 1) { | |
| returnAnimationId = requestAnimationFrame(animate); | |
| } else { | |
| returnAnimationId = null; | |
| } | |
| } | |
| returnAnimationId = requestAnimationFrame(animate); | |
| } | |
| /** | |
| * スムージングアニメーション | |
| * currentOffsetをtargetOffsetに滑らかに近づける | |
| */ | |
| function startSmoothingAnimation() { | |
| if (smoothingAnimationId) return; | |
| function animate() { | |
| const diff = targetOffset - currentOffset; | |
| if (Math.abs(diff) < 0.1) { | |
| currentOffset = targetOffset; | |
| updatePath(currentOffset); | |
| smoothingAnimationId = null; | |
| return; | |
| } | |
| // 補間係数(0.2 = 20%ずつ近づく) | |
| currentOffset += diff * 0.2; | |
| updatePath(currentOffset); | |
| smoothingAnimationId = requestAnimationFrame(animate); | |
| } | |
| smoothingAnimationId = requestAnimationFrame(animate); | |
| } | |
| /** | |
| * スムージングアニメーションを停止 | |
| */ | |
| function stopSmoothingAnimation() { | |
| if (smoothingAnimationId) { | |
| cancelAnimationFrame(smoothingAnimationId); | |
| smoothingAnimationId = null; | |
| } | |
| } | |
| /** | |
| * スクロール停止を検知して戻りアニメーションを開始 | |
| */ | |
| function scheduleReturnAnimation() { | |
| if (scrollEndTimer) { | |
| clearTimeout(scrollEndTimer); | |
| } | |
| scrollEndTimer = setTimeout(() => { | |
| if (isInView && currentOffset > 0) { | |
| stopSmoothingAnimation(); | |
| startReturnAnimation(); | |
| } | |
| }, CONFIG.scrollEndDelay); | |
| } | |
| /** | |
| * スクロールイベントハンドラ | |
| */ | |
| function handleScroll() { | |
| if (!isInView) return; | |
| // 戻りアニメーション中なら停止 | |
| if (returnAnimationId) { | |
| cancelAnimationFrame(returnAnimationId); | |
| returnAnimationId = null; | |
| } | |
| // スクロール速度を計算 | |
| const currentScrollY = window.scrollY; | |
| const currentTime = performance.now(); | |
| const deltaY = Math.abs(currentScrollY - lastScrollY); | |
| const deltaTime = currentTime - lastScrollTime; | |
| const velocity = deltaTime > 0 ? (deltaY / deltaTime) * 1000 : 0; | |
| // 速度からtargetOffsetを計算(上限あり) | |
| targetOffset = Math.min( | |
| velocity / CONFIG.velocityDivisor, | |
| CONFIG.maxOffset, | |
| ); | |
| // スムージングアニメーションを開始 | |
| startSmoothingAnimation(); | |
| // 状態を更新 | |
| lastScrollY = currentScrollY; | |
| lastScrollTime = currentTime; | |
| // スクロール停止検知をスケジュール | |
| scheduleReturnAnimation(); | |
| } | |
| /** | |
| * IntersectionObserver コールバック | |
| */ | |
| function handleIntersection(entries) { | |
| entries.forEach((entry) => { | |
| isInView = entry.isIntersecting; | |
| }); | |
| } | |
| // 初期化 | |
| function init() { | |
| // 初期パスを設定 | |
| updatePath(0); | |
| // IntersectionObserver を設定 | |
| const observer = new IntersectionObserver(handleIntersection, { | |
| threshold: 0, | |
| }); | |
| observer.observe(container); | |
| // スクロールイベントを設定 | |
| window.addEventListener("scroll", handleScroll, { passive: true }); | |
| } | |
| init(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment