これを翻訳してください: 童話『雲の向こうへ』読者対象アーカイビングインタラクションサイト
Over the Rainbowは、ペットを亡くした人々に慰めと思い出の場所を提供するインタラクティブなアーカイビングWEBです。死というテーマを重く扱うのではなく、暖かく軽い感情のニュアンスを保つことが最大の設計課題でした。
링크 정보를 불러오는 중...
それを実現するために、マルチレイヤーオーロラ、浮遊するSVGオブジェクト、手紙アーカイブ、参加型アップロードフローをライブラリなしで直接設計しました。視覚的に柔らかく暖かく見えるシーンの背後にも、ポインタ正規化、速度/加速度計算、ディスプレイスメント制御、レイヤープリセット設計、モバイル分岐が綿密に組み込まれています。
![]()
このプロジェクトの核心は、静的なグラデーションではなく、生きている膜のように見える背景を作ることでした。単にポインタ位置を追随させると、普通のパララックス効果に終わるため、移動速度と加速度を同時に計算し、速い手の動きほどskewとdisplacementが強くなるように設計しました。
onPointerEnterで lastTimestamp = performance.now()をリセットし、長時間マウス停止後でも即座に反応します。
レイヤーごとに , , , , を異なるようにして、異なる粘性を持った膜が重なって動くように見せました。マウスがない時でも各レイヤーが異なるリズムで揺れる理由です。spring補間に 正規化を適用し、60Hz〜144Hzのどんな環境でも同一の物理感覚を保証します。

feTurbulenceで有機的なノイズパターンを生成し、feDisplacementMapでレイヤーを歪ませました。最初は <animate> タグで baseFrequency を32秒周期で変更していただけでしたが、JS 制御に切り替えて、マウスの速度・加速度に応じてノイズパターン自体がリアルタイムで揺れるように改善しました。
displacement は8つのレイヤー全体に同一の scale を適用するのではなく、レイヤーごとに個別のフィルタ(#aurora-displace-N)を用意し、各レイヤーの現在波形中心とマウスの距離(layerProximity)を計算して、近いレイヤーほど強く歪むようにしました。

ポインターの反応トランスフォームはJSがrefを通じて直接更新し、基本的なドリフトアニメーションはCSSキーフレームが独立的に駆動します。2つのレイヤーを合成してインタラクションと背景の流れが互いを上書きしないように設計したことが核心です。
レイヤー設定値はaurora.config.tsに宣言形で集め、カラー・パレット、ブレンドモード、フロー速度、イナーシャ、パララックスを独立して修正できるようにしました。

手紙は一般的なカードリストとして配置されません。ハート、星、雲、円形のSVGオブジェクトが画面上をゆっくりと浮かび、ユーザーはこれを押して手紙を閲覧したり新しい手紙を残したりします。リストをスクロールしながら消費する感覚よりも、画面の中の記憶の断片を一つずつ発見する経験がこのサービスの感情により合っていました。
ランダムな配置を運に任せるとアイコンが互いに重なったり一方に集中したりして落ち着きません。衝突を避け、試みが失敗した場合は最も混雑していないエリアを再選択するleast-dense-zoneフォールバックで配置を安定させました。
ボタンはmouseenter、focus、touchstartの時点で画像を事前にプレフェッチして、モバイルおよびキーボード検索でもモーダルを開く直後の体感が荒くならないようにしました。

感性的なインタラクションが多いサイトはデスクトップでのみ見栄えが良く、モバイルでは不便になりがちです。このプロジェクトはタッチ環境で同じ物理反応を無理に維持せず、モバイルに合わせて反応の強度を調節する方向を選びました。
(pointer: coarse)でタッチ環境を検出し、モバイルではオーロラ物理ループをオフにしてグローバルスキューとディスプレイスメントを0に戻します。スクロールとタッチ入力が背景反応と衝突せず、小さい画面でインタラクションが過剰に感じられません。
リサイズはデバウンスをかけて頻繁な再計算を防ぎました。モーダルもデスクトップではカードのように、モバイルでは全画面で開くように設計し、狭い画面でも手紙作成と閲覧が不便でないようにしました。

小さい画面ではカードイメージが縮小して多くのカードが表示できるようにします。

サービスが感性的であるからといって入力体験まで軽くしてはいけません。手紙を直接残すプロセスが核心であるため、アップロードの前処理と作成体験も慎重に設計しました。
フォーム入力中にはdirty stateを検出し、閉じる際に確認なしで内容が消えないようにしました。
画像はアップロード前にブラウザで圧縮して送信量を減らし、プレビューまで即座に確認できるようにしました。遅いネットワーク環境やモバイルでアップロード失敗の可能性を減らすのに有利です。
保存はFirebase FirestoreとStorageで処理し、個人プロジェクトでも配布可能な参加型アーカイブの流れを完成しました。
モーダルを開いた時は背景のマウスインタラクションが作動しないように処理し、作成中のコンテンツに集中できるようにしました。
強いインタラクションがあるだけに、これを抑制したり止めたりする安全装置も一緒に設計しました。
モーダルにはrole="dialog"、を適用し、閉じるボタンにを付けてキーボードや補助機器ユーザーも文脈を失わないようにしました。ランダムオブジェクトも実際にはで実装し、クリック可能な要素をセマンティックに維持しました。
良いインタラクションとは、より多く動く画面ではなく、ユーザーの感情とデバイス環境に合わせて反応強度を調整する画面だと考えます。
単にアニメーションライブラリを使うのではなく、SVGとCSSとJSの責任を分離し、サービスの情緒に合った反応方式を自ら設計した作業だったので意味深かったです。
const calculateSpeed = (
nextX: number, nextY: number,
prevX: number, prevY: number,
deltaMs: number,
) => {
const distance = Math.hypot(nextX - prevX, nextY - prevY);
return clamp(distance * (16 / Math.max(8, deltaMs)) * 8, 0, 2.4);
};
const calculateAcceleration = (
nextSpeed: number, prevSpeed: number, deltaMs: number,
) =>
clamp(
Math.abs(nextSpeed - prevSpeed) * (16 / Math.max(8, deltaMs)) * 2,
0, 3.2,
);parallaxinertiarotationDirphaseOffsetflowSpeeddtFactor// prevFrameTimeで dt を測定し stiffness を正規化します。
const rawDt = timestamp - prevFrameTime;
prevFrameTime = timestamp;
const dtFactor = Math.min(rawDt, 50) / 16.667;
current.x += (target.x - current.x) * stiffness * dtFactor;
current.y += (target.y - current.y) * stiffness * dtFactor;
const phase = timestamp * 0.001 * layer.flowSpeed + layer.phaseOffset;
const waveX = Math.sin(phase) * 60 * layer.parallax;
const waveY = Math.cos(phase * 1.18) * 45 * layer.parallax;
const tx = (pointerX * layer.parallax + waveX) * inertiaFactor;
const ty = (pointerY * layer.parallax + waveY) * inertiaFactor;
const rot =
(current.x * 18 * baseGain * layer.rotationDir + waveRot) * inertiaFactor;
element.style.transform =
`translate3d(${tx.toFixed(2)}px, ${ty.toFixed(2)}px, 0) ` +
`rotate(${rot.toFixed(2)}deg) ` +
`skew(${skewX.toFixed(2)}deg, ${skewY.toFixed(2)}deg)`;
frameId = window.requestAnimationFrame(tick);// rAF ループ内で turbulenceRef を直接更新
const slowDrift = Math.sin(timestamp * 0.0001963) * 0.003; // 32秒周期のドリフト維持
const freqX = clamp(0.009 + slowDrift + current.speed * 0.004, 0.004, 0.022);
const freqY = clamp(0.016 + slowDrift * 0.8 + current.speed * 0.006, 0.008, 0.030);
turbulenceRef.current.setAttribute('baseFrequency', `${freqX.toFixed(4)} ${freqY.toFixed(4)}`);// このレイヤーの wave 中心とマウスの近接度
const layerCenterX = Math.sin(phase) * layer.parallax * 0.5;
const layerCenterY = Math.cos(phase * 1.18) * layer.parallax * 0.5;
const layerProximity = 1 - clamp(
Math.hypot(current.x - layerCenterX, current.y - layerCenterY) / 1.5, 0, 1
);
const perLayerScale = baseScale + dynamicStrength * 62 + layerProximity * 50 + …;
displacementRefs.current[index].setAttribute('scale', perLayerScale.toFixed(2));/* フィルターはレイヤーごとの固有ID(#aurora-displace-N)でインラインスタイルから適用 */
.motionDynamic {
animation-name: drift-dynamic;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}{
id: 'layer-blue',
motion: 'drift-dynamic',
durationSec: 18,
phaseOffset: 0.4,
flowSpeed: 0.8,
blendMode: 'screen',
physics: { parallax: 1.18, inertia: 0.1, rotationDir: 1 },
}if (!isOverlapping(x, y, size, positions, minPadding, paddingFactor)) {
const pos = { x, y, size };
positions.push(pos);
return pos;
}
const zone = pickLeastDenseZone(positions, maxWidth, maxHeight);<button
onMouseEnter={handlePrefetch}
onFocus={handlePrefetch}
onTouchStart={handlePrefetch}
onClick={() => {
if (!isInteractionEnabled) return;
handlePrefetch();
onClick(id);
}}
/>if (isTouchDevice) {
if (canvasRef.current) {
canvasRef.current.style.setProperty('--aurora-global-skew-x', '0deg');
canvasRef.current.style.setProperty('--aurora-global-skew-y', '0deg');
canvasRef.current.style.setProperty('--displacement-strength', '0');
}
return;
}if (width < 540) setBaseSize(150);
else if (width < 1024) setBaseSize(200);
else setBaseSize(300);const handleClose = () => {
if (isDirty && !confirm('作成した内容が削除されます。退出しますか?'))
return;
onClose();
};const compressedFile = await imageCompression(file, {
maxSizeMB: 0.5,
maxWidthOrHeight: 320,
useWebWorker: true,
});
setImage(compressedFile);<Letters
data={data}
onClick={(id) => toggleModal(id)}
isInteractionEnabled={!isOpened}
/>
<AuroraBackground isInteractionEnabled={!isOpened} />@media (prefers-reduced-motion: reduce) {
.layerWrap { transform: none !important; }
.motionGentle, .motionDynamic, .motionPulse {
animation: none !important;
}
}aria-labelbutton