동화책 '구름 너머로' 독자 대상 아카이빙 인터렉션 사이트
Over the Rainbow는 반려동물을 떠나보낸 사람들에게 위로와 추억의 공간을 제공하는 인터랙티브 아카이빙 웹입니다. 죽음이라는 주제를 무겁게만 다루는 대신, 따뜻하고 가벼운 감정의 결을 유지하는 것이 가장 큰 설계 과제였습니다.
링크 정보를 불러오는 중...
이를 위해 멀티 레이어 오로라, 부유하는 SVG 오브젝트, 편지 아카이브, 참여형 업로드 흐름을 라이브러리 없이 직접 설계했습니다. 시각적으로 부드럽고 따뜻해 보이는 장면 뒤에도 포인터 정규화, 속도/가속도 계산, displacement 제어, 레이어 프리셋 설계, 모바일 분기가 촘촘히 들어가 있습니다.
![]()
이 프로젝트의 핵심은 정적인 그래디언트가 아니라 살아 있는 막처럼 보이는 배경을 만드는 것이었습니다. 단순히 포인터 위치를 따라가게 만들면 평범한 parallax 효과에 그치기 때문에, 이동 속도와 가속도를 함께 계산해 빠른 손동작일수록 skew와 displacement가 더 강해지도록 설계했습니다.
onPointerEnter에서 lastTimestamp = performance.now()를 리셋해, 장시간 마우스 정지 후 진입 직후부터 즉각 반응합니다.
레이어마다 , , , , 를 다르게 적용해 서로 다른 점성을 가진 막이 겹쳐 움직이는 것처럼 보이게 했습니다. 마우스가 없을 때도 각 레이어가 서로 다른 리듬으로 울렁이는 이유입니다. spring 보간에 정규화를 적용해 60Hz~144Hz 어떤 환경에서도 동일한 물리 감각을 보장합니다.

feTurbulence로 유기적인 노이즈 패턴을 생성하고, feDisplacementMap으로 레이어를 왜곡했습니다. 처음에는 <animate> 태그로 baseFrequency를 32초 주기로만 변경했지만, JS 제어로 전환해 마우스 속도·가속도에 따라 노이즈 패턴 자체가 실시간으로 출렁이도록 개선했습니다.
displacement는 8개 레이어 전체에 동일한 scale을 적용하는 대신, 레이어별 개별 필터(#aurora-displace-N)를 두고 각 레이어의 현재 파형 중심과 마우스의 거리(layerProximity)를 계산해 가까운 레이어일수록 더 강하게 뒤틀리도록 했습니다.

포인터 반응 transform은 JS가 ref를 통해 직접 갱신하고, 기본 드리프트 애니메이션은 CSS keyframe이 독립적으로 구동합니다. 두 레이어를 합성해 인터랙션과 배경 흐름이 서로를 덮어쓰지 않도록 설계한 것이 핵심입니다.
레이어 설정값은 aurora.config.ts에 선언형으로 모아 색상 팔레트, blend mode, flow speed, inertia, parallax를 독립적으로 수정할 수 있게 했습니다.

편지는 일반적인 카드 목록으로 배치되지 않습니다. 하트, 별, 구름, 원 형태의 SVG 오브젝트가 화면 위를 천천히 떠다니고, 사용자는 이를 눌러 편지를 열람하거나 새로운 편지를 남깁니다. 리스트를 스크롤하며 소비하는 느낌보다, 화면 속 기억 조각을 하나씩 발견하는 경험이 이 서비스의 정서에 더 맞았습니다.
랜덤 배치를 운에만 맡기면 아이콘이 서로 겹치거나 한쪽에 몰려 어수선해집니다. 충돌을 피하고, 시도가 실패하면 가장 덜 붐비는 구역을 다시 선택하는 least-dense-zone fallback으로 배치를 안정화했습니다.
버튼은 mouseenter, focus, touchstart 시점에 이미지를 미리 prefetch해, 모바일과 키보드 탐색에서도 모달 열림 직후 체감이 거칠지 않게 했습니다.

감성적 인터랙션이 많은 사이트는 데스크톱에서만 보기 좋고 모바일에서 불편해지기 쉽습니다. 이 프로젝트는 터치 환경에서 같은 물리 반응을 억지로 유지하지 않고, 모바일에 맞게 반응 강도를 조절하는 방향을 택했습니다.
(pointer: coarse)로 터치 환경을 감지하고, 모바일에서는 오로라 물리 루프를 꺼 global skew와 displacement를 0으로 되돌립니다. 스크롤과 터치 입력이 배경 반응과 충돌하지 않고, 작은 화면에서 인터랙션이 과하게 느껴지지 않습니다.
resize는 debounce를 걸어 잦은 재계산을 방지했습니다. 모달도 데스크톱에서는 카드처럼, 모바일에서는 전체 화면으로 열리도록 설계해 좁은 화면에서도 편지 작성과 읽기가 불편하지 않게 했습니다.

작은 화면에선 카드 이미지가 줄어들어 많은 카드가 나올 수 있도록 합니다.

서비스가 감성적이라고 해서 입력 경험까지 가볍게 만들면 안 됩니다. 편지를 직접 남기는 흐름이 핵심인 만큼, 업로드 전처리와 작성 경험도 꼼꼼히 설계했습니다.
폼 작성 중에는 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));/* filter는 레이어별 고유 ID(#aurora-displace-N)로 inline style에서 적용 */
.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