Skip to content

Instantly share code, notes, and snippets.

@kobitoDevelopment
Last active January 24, 2026 13:24
Show Gist options
  • Select an option

  • Save kobitoDevelopment/216ce5ae4338e0f719cff6b5891d1e6a to your computer and use it in GitHub Desktop.

Select an option

Save kobitoDevelopment/216ce5ae4338e0f719cff6b5891d1e6a to your computer and use it in GitHub Desktop.
.container {
height: 300vh;
}
.ornament-container {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
z-index: 100;
}
.ornament-svg {
display: block;
width: 100%;
overflow: visible;
}
<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>
/**
* 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