An interactive archiving site for readers of the fairy tale book «Beyond the Clouds»
«Over the Rainbow» is an interactive archiving web platform that provides a space of comfort and memories for people who have lost their pets. Instead of dealing with the subject of death heavily, maintaining a warm and light emotional touch was the most significant design challenge.
링크 정보를 불러오는 중...
For this purpose, multi-layer auroras, floating SVG objects, letter archives, and a participatory upload flow were designed directly without libraries. Behind visually soft and warm-looking scenes, point normalization, speed/acceleration calculations, displacement control, layer preset design, and mobile branching are intricately incorporated.
![]()
The core of this project was to create a background that looks like a living membrane rather than static gradients. Simply following the pointer position results in an ordinary parallax effect, so we calculated travel speed and acceleration, designing it so that faster hand movements make the skew and displacement stronger.
Reset lastTimestamp = performance.now() in onPointerEnter to react immediately right after entering, even after the mouse has stopped for a long time.
By applying different , , , , and to each layer, we made it appear as if layers with different viscosities overlapped and moved. This is why each layer swells in a different rhythm even when there is no mouse. By applying normalization to spring interpolation, the same physical feel is guaranteed in any environment from 60Hz to 144Hz.

We generated an organic noise pattern with feTurbulence and distorted layers with feDisplacementMap. Initially, we only changed the baseFrequency in cycles of 32 seconds using the tag, but switched to JS control to make the noise pattern itself ripple in real-time according to mouse speed and acceleration.
Instead of applying the same scale to all 8 layers' displacement, each layer has its own filter (#aurora-displace-N), calculating the current waveform center and mouse distance (layerProximity) for each layer, making layers closer to the mouse twist more strongly.

Pointer response transform is directly updated by JS through ref, while the basic drift animation is independently driven by CSS keyframes. The key is designing the composition of these two layers so that interactions and background flow do not overwrite each other.
Layer settings are collected declaratively in aurora.config.ts, allowing independent modifications of color palette, blend mode, flow speed, inertia, and parallax.

Letters are not arranged as a typical card list. SVG objects in heart, star, cloud, and circle shapes float slowly across the screen, allowing users to tap them to read letters or leave new ones. This service seemed more emotionally suited to the experience of discovering memory fragments on the screen one by one, rather than consuming a list by scrolling.
If left solely to random placement, icons can overlap each other or cluster in one area, creating disarray. Overlaps are avoided by stabilizing placement with a least-dense-zone fallback that selects the least crowded area if an attempt fails.
Images are pre-fetched when buttons are mouseenter, focus, or touchstart, ensuring a smooth experience right after the modal opens, even on mobile and with keyboard navigation.

The websites with a lot of emotional interaction often look good only on desktops, becoming inconvenient on mobile devices. This project opted to adjust the reaction intensity suitable for mobile, rather than forcibly maintaining the same physical reactions in a touch environment.
Detect the touch environment with (pointer: coarse), and on mobile, turn off the aurora physical loop to revert the global skew and displacement to 0. This prevents scroll and touch inputs from conflicting with background reactions, and interaction does not feel excessive on smaller screens.
Resize is debounced to prevent frequent recalculations. Modals are designed to open like cards on desktops and fullscreen on mobile, ensuring that composing and reading letters are not inconvenient on narrow screens.

On smaller screens, card images shrink to allow more cards to appear.

Even if the service is emotional, the input experience should not be made too light. As the flow of leaving letters is key, the pre-processing and writing experience were meticulously designed.
While writing a form, detect the dirty state, so that content is not lost upon closing without confirmation.
The images are compressed in the browser before uploading to reduce transmission volume and allow immediate preview. This is advantageous in reducing the probability of upload failures in slow network environments or mobile.
Storage is handled with Firebase Firestore and Storage, completing the flow of a participatory archive deployable even in personal projects.
When the modal is opened, the mouse interaction with the background is disabled to allow focus on the content being written.
Given the strong interactions, safety mechanisms to mitigate or halt them have also been designed.
Modals are implemented with role="dialog", , and is added to close buttons to ensure that keyboard and assistive device users do not lose context. Random objects are semantically maintained as elements for clickable components.
I believe that good interaction involves screens that adjust response intensity according to the user’s emotions and device environment, rather than just more animated screens.
This was a meaningful task where, instead of simply adding animation libraries, I designed reaction methods that align with the service's sentiment by separating the responsibilities of SVG, CSS, and 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// Measure dt with prevFrameTime to normalize 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);// Direct updates with turbulenceRef inside the rAF loop
const slowDrift = Math.sin(timestamp * 0.0001963) * 0.003; // Maintain drift cycle of 32 seconds
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)}`);// Proximity of this layer's wave center to the mouse
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));/* The filter is applied in inline style with a unique ID per layer (#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('Your content will be deleted. Do you want to leave?'))
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-labelbuttonPersonal Portfolio · Article Archiving Platform
A gamification platform that turns boring CS studies into enjoyable habits
An interactive archiving site for readers of the fairy tale book «Beyond the Clouds»
A combination markdown editor package that the host app can directly control
마지막 프로젝트까지 모두 확인했습니다.