Site d'interaction pour les lecteurs du livre pour enfants 'Au-delà des Nuages'
Au-delà de l'Arc-en-ciel est un site web interactif d'archivage qui offre un espace de réconfort et de souvenirs aux personnes ayant perdu leur animal de compagnie. Plutôt que de traiter le sujet de la mort de manière lourde, le plus grand défi de conception a été de maintenir une touche émotionnelle chaleureuse et légère.
링크 정보를 불러오는 중...
Pour cela, nous avons conçu sans bibliothèque des auroras à couches multiples, des objets SVG flottants, un archivage de lettres et un flux de téléchargement participatif. Derrière des scènes visuellement douces et chaleureuses, se cachent également des normalisations de pointeur, des calculs de vitesse/accélération, des contrôles de déplacement, la conception de préréglages de couches, et des branches mobiles minutieusement intégrées.
![]()
Le cœur de ce projet était de créer un fond qui semble vivant comme un voile, plutôt qu'un simple dégradé statique. Si nous nous contentons de suivre la position du pointeur, le résultat serait un effet de parallaxe ordinaire. C'est pourquoi nous avons conçu le système de manière à ce que la vitesse et l'accélération de déplacement soient également prises en compte, de sorte que les gestes rapides de la main entraînent un dispositif de décalage et de déplacement plus prononcé.
onPointerEnter réinitialise lastTimestamp = performance.now(), pour réagir immédiatement après une longue pause du curseur.
Pour chaque couche, nous avons appliqué différents , , , , afin de créer l'illusion de voiles superposés animés avec différentes viscosités. Cela explique pourquoi chaque couche semble palpiter avec un rythme différent même en l'absence de souris. Avec l'application de la normalisation de l'interpolation par ressort via , la même sensation physique est garantie quelle que soit la fréquence, de 60Hz à 144Hz.

Nous avons généré un pattern de bruit organique avec feTurbulence et déformé les couches avec feDisplacementMap. À l'origine, nous avions utilisé la balise pour changer la baseFrequency sur un cycle de 32 secondes, mais nous sommes passés à un contrôle JS permettant au pattern de bruit de fluctuer en temps réel, en se basant sur la vitesse et l'accélération de la souris.
Au lieu d'appliquer la même échelle de displacement sur les 8 couches, nous avons ajouté un filtre individuel par couche (#aurora-displace-N) et calculé la proximité entre le centre du waveform actuel de chaque couche et la souris (layerProximity), de manière à ce que les couches plus proches se déforment plus fortement.

La transformation réactive au pointeur est mise à jour directement par JS via ref, tandis que l'animation de dérive de base est animée indépendamment par les keyframes CSS. Le cœur de la conception est de combiner les deux couches pour que l'interaction et le flux d'arrière-plan ne se chevauchent pas.
Les paramètres de couche sont collectés de manière déclarative dans aurora.config.ts, permettant de modifier indépendamment la palette de couleurs, le mode de fusion, la vitesse de flux, l'inertie et le parallaxe.

Les lettres ne sont pas disposées comme une liste de cartes classique. Des objets SVG en forme de cœur, d'étoiles, de nuages et de cercles flottent lentement sur l'écran, et l'utilisateur peut cliquer dessus pour lire ou écrire une nouvelle lettre. Plutôt qu'un simple défilement de liste, l'expérience consiste à découvrir un à un des fragments de souvenirs à l'écran, ce qui correspond mieux à l'esprit du service.
Confier la disposition au hasard peut entraîner des chevauchements d'icônes ou une accumulation désordonnée d'un côté. Pour éviter les collisions, nous avons utilisé un fallback vers la zone la moins dense si la tentative échouait, stabilisant ainsi la disposition.
Le bouton précharge les images au moment où l'événement mouseenter, focus ou touchstart est déclenché, afin que l'ouverture du modal soit fluide même lors de la navigation sur mobile ou au clavier.

Les sites avec beaucoup d'interactions émotionnelles sont souvent agréables sur ordinateur mais deviennent inconfortables sur mobile. Ce projet a choisi d’ajuster l’intensité des réactions pour les adapter aux mobiles, sans forcer les mêmes réponses physiques que sur les environnements tactiles.
En détectant l’environnement tactile avec (pointer: coarse), la boucle physique aurora est désactivée sur mobile, et les valeurs globales de distorsion et de déplacement sont réinitialisées à 0. Les défilements et les entrées tactiles ne se heurtent pas aux réactions d’arrière-plan, et les interactions ne sont pas ressenties comme excessives sur les petits écrans.
Le redimensionnement a été associé à un délai pour éviter des recalculs fréquents. Les modales sont conçues pour s'ouvrir en plein écran sur mobile et en forme de carte sur ordinateur, afin de faciliter la rédaction et la lecture des lettres sur des écrans étroits.

Sur les petits écrans, la taille des images des cartes diminue pour permettre l’affichage de plusieurs cartes.

Un service axé sur les émotions ne doit pas offrir une expérience de saisie négligente. Comme la rédaction de lettres est centrale, le prétraitement de téléchargement et l’expérience utilisateur ont été soigneusement conçus.
Pendant la rédaction d’un formulaire, un état modifié (« dirty state ») est détecté pour éviter toute perte de contenu sans confirmation lors de la fermeture.
Les images sont compressées dans le navigateur avant le téléchargement pour réduire le volume de données envoyées et permettre une prévisualisation immédiate. Cela réduit les risques d'échec de téléchargement en environnements réseau lents ou sur mobile.
Les données sont sauvegardées dans Firebase Firestore et Storage pour créer un flux d'archives participatif déployable, même pour un projet personnel.
Lors de l'ouverture d'une modale, les interactions par souris en arrière-plan sont désactivées pour permettre de se concentrer sur le contenu en cours de rédaction.
Étant donné la forte interaction, nous avons également conçu des dispositifs de sécurité pour la modérer ou l'arrêter.
Les modales ont l'attribut role="dialog", appliqué et un bouton de fermeture avec pour que les utilisateurs de claviers et appareils d'assistance ne perdent pas le contexte. Les objets aléatoires sont en fait implémentés comme pour maintenir les éléments cliquables sémantiquement.
Je pense qu'une bonne interaction n'est pas un écran qui bouge plus, mais un écran qui ajuste l'intensité de la réponse en fonction des émotions de l'utilisateur et de l'environnement de l'appareil.
Ce fut un travail enrichissant de ne pas simplement ajouter une bibliothèque d'animations, mais de concevoir nous-mêmes le mode de réponse adapté au sentiment du service, en séparant les responsabilités de SVG, CSS et 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// Mesurez dt à partir de prevFrameTime pour normaliser le 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);// Mettez à jour directement avec turbulenceRef dans la boucle rAF
const slowDrift = Math.sin(timestamp * 0.0001963) * 0.003; // Maintien du dérive de 32 secondes
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)}`);// Proximité du centre du wave de cette couche par rapport à la souris
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));/* Le filtre est appliqué dans le style inline avec un ID unique par couche (#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('Le contenu rédigé sera supprimé. Voulez-vous vraiment quitter ?'))
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-labelbuttonPortfolio personnel · Site d'archivage d'articles
Une plateforme de gamification qui transforme l'étude en informatique en une habitude agréable
Site d'interaction pour les lecteurs du livre pour enfants 'Au-delà des Nuages'
Un package d'éditeur Markdown combiné que l'application hôte peut contrôler directement
마지막 프로젝트까지 모두 확인했습니다.