テーマ切り替えインタラクション:電球のひもを引くUIの解説 (Svelte)
このコードは、Webサイトのダークモードとライトモードを切り替える独創的なUIを実装しています。ユーザーは電球の下にぶら下がる紐をドラッグして引くことで、サイトのテーマを切り替えることができます。このインタラクションは、現実世界の電球の紐を引く動作をデジタルで再現した直感的なデザインとなっています。
基本構造と状態管理
import { onMount } from "svelte";
import { elasticOut } from "svelte/easing";
let x1 = 20; // 固定始点
let y1 = 0; // 固定始点
let x2 = $state(20); // 終点の初期位置は始点の下
let y2 = $state(80); // 終点の初期位置
const initialY2 = 80; // y2の初期位置を保存
const maxOffsetX = 10; // x軸の許容範囲
このコードではSvelteのリアクティブな状態管理を利用しています。$state
を使ってx2
とy2
の値が変更されると、関連するUIが自動的に更新されます。紐の始点(x1
,y1
)は固定されており、終点(x2
,y2
)はドラッグ操作によって動きます。
initialY2
は紐の終点の初期位置を保存し、紐がこの位置より上に移動しないように制限する際に使用されます。また、maxOffsetX
は紐が左右に動ける範囲を制限します。
ドラッグ操作の実装
function handleMove(event: MouseEvent | TouchEvent) {
if (!isDragging) {
return;
}
// タッチイベントかマウスイベントかを判断
const touch = event instanceof TouchEvent ? event.touches[0] : null;
const clientX = touch ? touch.clientX : (event as MouseEvent).clientX;
const clientY = touch ? touch.clientY : (event as MouseEvent).clientY;
// SVG座標系に変換
const svg = document.getElementById("string") as SVGSVGElement | null;
if (!svg) {
return;
}
const point = svg.createSVGPoint();
point.x = clientX;
point.y = clientY;
const cursorPoint = point.matrixTransform(svg.getScreenCTM()!.inverse());
// 座標更新と制約
y2 = cursorPoint.y;
x2 = Math.min(Math.max(cursorPoint.x, x1 - maxOffsetX), x1 + maxOffsetX);
// 始点から終点までの距離を制限
const distance = Math.abs(y2 - y1);
if (distance > maxDistance) {
y2 = y1 + maxDistance * Math.sign(y2 - y1);
}
// y2が初期位置より上に行かないように制限
if (y2 < initialY2) {
y2 = initialY2;
}
}
この関数は紐の終点をドラッグしたときの動作を制御します。マウスとタッチの両方のイベントに対応しているため、PCとモバイル端末どちらでも操作できます。
重要なポイントは以下の通りです:
- ブラウザの座標系(クライアント座標)からSVGの座標系に変換する処理
- 紐の動きに様々な制約を設けている点:
- 左右の動きは
maxOffsetX
で制限 - 紐の長さは
maxDistance
で制限 - 紐は初期位置より上には移動できない
- 左右の動きは
これらの制約によって、紐の動きが自然に見えるようになっています。
ドラッグの開始と終了
function handleStart(event: MouseEvent | TouchEvent) {
// 省略...
const distanceToEnd = Math.hypot(cursorPoint.x - x2, cursorPoint.y - y2);
if (distanceToEnd <= endCircleRadius) {
isDragging = true;
cancelAnimationFrame(animationFrame); // ドラッグ開始時にアニメーションを止める
}
}
function handleEnd() {
if (isDragging) {
isDragging = false;
// ドラッグ操作が moveThreshold を超えていた場合のみ色を切り替える
if (y2 > moveThreshold) {
const currentTheme = document.body.classList.contains("theme-light")
? "theme-light"
: "theme-dark";
if (currentTheme === "theme-light") {
bulbArea1 = "yellow";
applyThemeClass("theme-dark");
} else {
bulbArea1 = "white";
applyThemeClass("theme-light");
}
saveThemeToLocalStorage(
document.body.classList.contains("theme-light")
? "theme-light"
: "theme-dark"
);
}
animateEndPoint(); // ドラッグ終了時に始点の直下に戻るアニメーションを開始
}
}
handleStart
関数では、ユーザーがドラッグを開始できるのは紐の終点(小さな円)の中だけに限定されています。これにより、紐以外の部分をクリックしても反応しないようになっています。
handleEnd
関数では、ドラッグが終了した時に以下の処理を行います:
- ドラッグの距離が閾値(
moveThreshold
)を超えていた場合のみテーマを切り替え - 現在のテーマに基づいて電球の色と新しいテーマを設定
- 選択されたテーマをローカルストレージに保存して次回訪問時も維持
- 紐が自然に元の位置に戻るアニメーションを開始
テーマの適用と保存
function applyThemeClass(theme: string) {
// 既存のテーマクラスを削除
document.body.classList.remove("theme-dark", "theme-light");
// 新しいテーマクラスを追加
document.body.classList.add(theme);
// カスタムイベントを発火して新しい要素にも適用
const event = new Event("themeChanged");
document.body.dispatchEvent(event);
}
function saveThemeToLocalStorage(theme: string) {
localStorage.setItem("colors", JSON.stringify({ theme }));
}
function observeNewElements() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
// 新しいノードが追加されたときにテーマを適用
document.body.dispatchEvent(new Event("themeChanged"));
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
このセクションではテーマの適用と保存に関する機能を提供しています:
applyThemeClass
関数は、ドキュメント全体にテーマクラスを適用し、カスタムイベントを発火させますsaveThemeToLocalStorage
関数は、選択されたテーマをブラウザのローカルストレージに保存しますobserveNewElements
関数はMutationObserver
を使用して、動的に追加される新しい要素にもテーマが適用されるようにします
特にMutationObserver
の使用は、ページにコンテンツが動的に追加された場合でもテーマが一貫して適用されることを保証するための先進的なアプローチです。
スプリングアニメーション
function animateEndPoint() {
const startX = x2;
const startY = y2;
const endX = x1;
const endY = y1 + 80; // 始点の直下に戻る
const duration = 3000; // アニメーションの持続時間 (ミリ秒)
const startTime = performance.now();
function animate(time: number) {
const elapsed = time - startTime;
let progress = Math.min(elapsed / duration, 1);
// 'elasticOut'を使って滑らかな終了を実現
const easingProgress = elasticOut(progress);
// X軸の波打つ効果を控えめに
x2 =
startX +
(endX - startX) * easingProgress +
10 * Math.sin(progress * 5 * Math.PI);
// Y軸は直線的に戻る
y2 = startY + (endY - startY) * easingProgress;
if (progress < 1) {
animationFrame = requestAnimationFrame(animate);
}
}
animationFrame = requestAnimationFrame(animate);
}
このアニメーション関数は、ドラッグが終了した後に紐が自然に元の位置に戻る動きを生成します。特徴的な点は以下の通りです:
- Svelteの
elasticOut
イージング関数を使用して、バネのような弾力性のある動きを実現 - X軸に正弦波の揺れを加えることで、紐が揺れながら戻る自然な動きを表現
requestAnimationFrame
によるスムーズなアニメーション
このアニメーションにより、電球の紐を引いた後に実際の紐のように揺れながら元に戻る様子をリアルに再現しています。
コンポーネントの初期化
onMount(() => {
// ページ読み込み時に色の状態を復元
const savedColors = localStorage.getItem("colors");
if (savedColors) {
const { theme } = JSON.parse(savedColors);
// 保存されたテーマクラスを適用
applyThemeClass(theme);
} else {
// 初期テーマクラスを適用して保存
const initialTheme = "theme-dark";
applyThemeClass(initialTheme);
saveThemeToLocalStorage(initialTheme);
}
// 要素の追加を監視して、後から追加された要素にも適用
observeNewElements();
// イベントリスナーを設定
// ...省略...
return () => {
// クリーンアップ処理
// ...省略...
};
});
onMount
ライフサイクルフックでは、コンポーネントがDOMに追加されたときに以下の初期化処理を行います:
- ローカルストレージからユーザーの前回のテーマ設定を読み込む
- 保存されたテーマがない場合はデフォルトのダークテーマを適用
- 動的に追加される要素の監視を開始
- マウスとタッチイベントのリスナーを設定
また、コンポーネントが破棄されるときのクリーンアップ処理も含まれており、メモリリークを防ぐためのイベントリスナーの削除が行われます。
まとめ
このコードは単なるテーマ切り替え機能にとどまらず、物理的なインタラクションをデジタルで模倣することで、ユーザーエクスペリエンスを大きく向上させています。実装のポイントは以下のとおりです:
- SVGを使用した紐のインタラクティブな描画
- 物理法則に基づいたリアルな動きの制約
- 弾力性のあるアニメーションによる自然な動き
- ローカルストレージによるユーザー設定の保存
- 動的に追加される要素にもテーマを適用するための監視機構
このようなディテールへのこだわりが、通常の単調なテーマ切り替えボタンと比較して、より楽しく記憶に残るユーザー体験を作り出しています。