feat: WebGL cinematic countdown — 3D atom with circular orbital text

- Three.js + React Three Fiber + postprocessing (bloom, chromatic aberration, glitch, scanlines)
- Nucleus with pulsing energy core, wireframe icosahedron shell
- 3 electron orbits with trail particles + energy arcs
- 600 particle cloud with organic movement
- Circular orbital text: title, countdown timer, subtitle curved on 3D rings
- Mouse interactions: camera parallax, atom speed boost, nucleus glow on proximity
- Camera smooth lerp follow with gentle auto-drift
- Font fix: woff2 → ttf for troika-three-text compatibility
- Logo: static mark instead of missing sprite frames

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Clément SAILLANT
2026-03-29 17:32:38 +00:00
co-authored by Claude Opus 4.6
parent 666f05bf59
commit d5f1ce84b4
5 changed files with 1317 additions and 201 deletions
+526
View File
@@ -0,0 +1,526 @@
import { useRef, useMemo, useEffect, useState, useCallback } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text } from '@react-three/drei';
import { EffectComposer, Bloom, ChromaticAberration, Scanline, Glitch as GlitchEffect } from '@react-three/postprocessing';
import { GlitchMode, BlendFunction } from 'postprocessing';
import * as THREE from 'three';
/* ===================================================================
ATOM GLITCH — cinematic WebGL countdown for L'Electron Rare
- Nucleus core with pulsing energy
- 3 electron orbits with trail particles
- Particle cloud (electrons as sparks)
- Post-processing: bloom, chromatic aberration, glitch, scanlines
- Mouse-reactive + auto-orbit
=================================================================== */
const COLORS = {
cyan: new THREE.Color('#5bd1d8'),
amber: new THREE.Color('#f1c27a'),
green: new THREE.Color('#b6d18f'),
electric: new THREE.Color('#0071e3'),
white: new THREE.Color('#ffffff'),
nucleus: new THREE.Color('#ff6b35'),
};
/* ---------- Particle cloud around the atom ---------- */
function ParticleField({ count = 600 }) {
const ref = useRef<THREE.Points>(null);
const { positions, velocities, colors } = useMemo(() => {
const pos = new Float32Array(count * 3);
const vel = new Float32Array(count * 3);
const col = new Float32Array(count * 3);
const palette = [COLORS.cyan, COLORS.amber, COLORS.green, COLORS.electric];
for (let i = 0; i < count; i++) {
const r = 2 + Math.random() * 6;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
pos[i * 3 + 2] = r * Math.cos(phi);
vel[i * 3] = (Math.random() - 0.5) * 0.004;
vel[i * 3 + 1] = (Math.random() - 0.5) * 0.004;
vel[i * 3 + 2] = (Math.random() - 0.5) * 0.004;
const c = palette[Math.floor(Math.random() * palette.length)];
col[i * 3] = c.r;
col[i * 3 + 1] = c.g;
col[i * 3 + 2] = c.b;
}
return { positions: pos, velocities: vel, colors: col };
}, [count]);
useFrame(({ clock }) => {
if (!ref.current) return;
const posAttr = ref.current.geometry.attributes.position as THREE.BufferAttribute;
const arr = posAttr.array as Float32Array;
const t = clock.getElapsedTime();
for (let i = 0; i < count; i++) {
arr[i * 3] += velocities[i * 3] + Math.sin(t * 0.5 + i) * 0.002;
arr[i * 3 + 1] += velocities[i * 3 + 1] + Math.cos(t * 0.3 + i) * 0.002;
arr[i * 3 + 2] += velocities[i * 3 + 2];
// respawn far particles
const dist = Math.sqrt(arr[i * 3] ** 2 + arr[i * 3 + 1] ** 2 + arr[i * 3 + 2] ** 2);
if (dist > 8) {
const r = 2 + Math.random() * 2;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
arr[i * 3] = r * Math.sin(phi) * Math.cos(theta);
arr[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
arr[i * 3 + 2] = r * Math.cos(phi);
}
}
posAttr.needsUpdate = true;
});
return (
<points ref={ref}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
<pointsMaterial size={0.04} vertexColors transparent opacity={0.7} sizeAttenuation depthWrite={false} blending={THREE.AdditiveBlending} />
</points>
);
}
/* ---------- Orbit trail (instanced tube of spheres) ---------- */
function OrbitTrail({ tilt, speed, color, trailCount = 30 }: { tilt: number[]; speed: number; color: THREE.Color; trailCount?: number }) {
const meshRef = useRef<THREE.InstancedMesh>(null);
const phase = useMemo(() => Math.random() * Math.PI * 2, []);
const dummy = useMemo(() => new THREE.Object3D(), []);
const orbitA = 3.2;
const orbitB = orbitA * 0.38;
useFrame(({ clock }) => {
if (!meshRef.current) return;
const t = clock.getElapsedTime() * speed + phase;
for (let i = 0; i < trailCount; i++) {
const age = i / trailCount;
const angle = t - age * 0.8;
dummy.position.set(Math.cos(angle) * orbitA, Math.sin(angle) * orbitB, 0);
const s = (1 - age) * 0.14 + 0.02;
dummy.scale.setScalar(s);
dummy.updateMatrix();
meshRef.current.setMatrixAt(i, dummy.matrix);
}
meshRef.current.instanceMatrix.needsUpdate = true;
});
return (
<group rotation={tilt as [number, number, number]}>
<instancedMesh ref={meshRef} args={[undefined, undefined, trailCount]}>
<sphereGeometry args={[1, 8, 8]} />
<meshStandardMaterial color={color} emissive={color} emissiveIntensity={2} transparent opacity={0.9} />
</instancedMesh>
</group>
);
}
/* ---------- Orbit ring (thin glowing line) ---------- */
function OrbitRing({ tilt, color }: { tilt: number[]; color: THREE.Color }) {
const points = useMemo(() => {
const pts: THREE.Vector3[] = [];
for (let i = 0; i <= 128; i++) {
const t = (i / 128) * Math.PI * 2;
pts.push(new THREE.Vector3(Math.cos(t) * 3.2, Math.sin(t) * 3.2 * 0.38, 0));
}
return pts;
}, []);
const geo = useMemo(() => new THREE.BufferGeometry().setFromPoints(points), [points]);
return (
<group rotation={tilt as [number, number, number]}>
<line geometry={geo}>
<lineBasicMaterial color={color} transparent opacity={0.12} />
</line>
</group>
);
}
/* ---------- Nucleus: pulsing energy core ---------- */
function Nucleus() {
const coreRef = useRef<THREE.Mesh>(null);
const glowRef = useRef<THREE.Mesh>(null);
const outerRef = useRef<THREE.Mesh>(null);
useFrame(({ clock, pointer }) => {
const t = clock.getElapsedTime();
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
const mouseProximity = Math.max(0, 1 - mouseDist); // 1 at center, 0 at edges
if (coreRef.current) {
const s = 1 + Math.sin(t * 3) * 0.08 + Math.sin(t * 7.3) * 0.03 + mouseProximity * 0.15;
coreRef.current.scale.setScalar(s);
(coreRef.current.material as THREE.MeshStandardMaterial).emissiveIntensity = 2 + mouseProximity * 3;
}
if (glowRef.current) {
const s = 1 + Math.sin(t * 2) * 0.15 + mouseProximity * 0.3;
glowRef.current.scale.setScalar(s);
(glowRef.current.material as THREE.MeshStandardMaterial).opacity = 0.15 + Math.sin(t * 4) * 0.08 + mouseProximity * 0.2;
}
if (outerRef.current) {
outerRef.current.rotation.y = t * (0.5 + mouseProximity * 2);
outerRef.current.rotation.x = t * (0.3 + mouseProximity * 1.5);
}
});
return (
<group>
{/* outer energy shell */}
<mesh ref={outerRef}>
<icosahedronGeometry args={[0.9, 1]} />
<meshStandardMaterial color={COLORS.electric} wireframe transparent opacity={0.08} emissive={COLORS.electric} emissiveIntensity={0.5} />
</mesh>
{/* glow sphere */}
<mesh ref={glowRef}>
<sphereGeometry args={[0.7, 32, 32]} />
<meshStandardMaterial color={COLORS.nucleus} transparent opacity={0.15} emissive={COLORS.nucleus} emissiveIntensity={1.2} />
</mesh>
{/* solid core */}
<mesh ref={coreRef}>
<sphereGeometry args={[0.32, 32, 32]} />
<meshStandardMaterial color="#ffffff" emissive={COLORS.amber} emissiveIntensity={2} metalness={0.6} roughness={0.2} />
</mesh>
</group>
);
}
/* ---------- Energy arcs (random lightning) ---------- */
function EnergyArc({ color }: { color: THREE.Color }) {
const ref = useRef<THREE.Line>(null);
const [visible, setVisible] = useState(true);
useFrame(({ clock }) => {
const t = clock.getElapsedTime();
// flash randomly
setVisible(Math.sin(t * 12 + Math.random() * 100) > 0.7);
if (ref.current && visible) {
const geo = ref.current.geometry;
const pts: THREE.Vector3[] = [];
const segments = 8;
const startAngle = Math.sin(t * 2) * Math.PI;
for (let i = 0; i <= segments; i++) {
const frac = i / segments;
const r = 0.4 + frac * 2.5;
const angle = startAngle + frac * 1.5;
pts.push(new THREE.Vector3(
Math.cos(angle) * r + (Math.random() - 0.5) * 0.3,
Math.sin(angle) * r * 0.4 + (Math.random() - 0.5) * 0.2,
(Math.random() - 0.5) * 0.4,
));
}
geo.setFromPoints(pts);
}
});
if (!visible) return null;
return (
<line ref={ref}>
<bufferGeometry />
<lineBasicMaterial color={color} transparent opacity={0.4} blending={THREE.AdditiveBlending} />
</line>
);
}
/* ---------- 3D Circular / Orbital text ---------- */
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
const FONT_URL = '/assets/fonts/manrope-regular.ttf';
function pad(n: number) { return String(n).padStart(2, '0'); }
/* Each character placed on a circle arc, rotating together */
function CircularText({ text, radius, speed, tilt, fontSize, color, emissive, opacity = 0.9, spread = 0.8 }: {
text: string; radius: number; speed: number; tilt: number[];
fontSize: number; color: string; emissive: string; opacity?: number; spread?: number;
}) {
const groupRef = useRef<THREE.Group>(null);
const chars = useMemo(() => text.split(''), [text]);
const charWidth = fontSize * 0.55;
useFrame(({ clock }) => {
if (!groupRef.current) return;
const t = clock.getElapsedTime() * speed;
groupRef.current.rotation.y = t;
});
return (
<group rotation={tilt as [number, number, number]}>
<group ref={groupRef}>
{chars.map((char, i) => {
const angle = ((i - chars.length / 2) * charWidth * spread) / radius;
const x = Math.sin(angle) * radius;
const z = Math.cos(angle) * radius;
return (
<Text
key={i}
position={[x, 0, z]}
rotation={[0, -angle, 0]}
fontSize={fontSize}
font={FONT_URL}
anchorX="center"
anchorY="middle"
>
{char}
<meshStandardMaterial
color={color}
emissive={emissive}
emissiveIntensity={0.6}
transparent
opacity={opacity}
side={THREE.DoubleSide}
/>
</Text>
);
})}
</group>
</group>
);
}
/* Countdown — characters on a spinning ring */
function CountdownRing() {
const [time, setTime] = useState('');
const groupRef = useRef<THREE.Group>(null);
const radius = 4.2;
useEffect(() => {
function tick() {
const diff = Math.max(0, LAUNCH - Date.now());
const d = Math.floor(diff / 86400000);
const h = Math.floor((diff % 86400000) / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
const s = Math.floor((diff % 60000) / 1000);
setTime(`${pad(d)}J ${pad(h)}H ${pad(m)}M ${pad(s)}S`);
}
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
useFrame(({ clock }) => {
if (!groupRef.current) return;
groupRef.current.rotation.y = clock.getElapsedTime() * -0.2;
});
const chars = time.split('');
const fontSize = 0.28;
const charWidth = fontSize * 0.5;
return (
<group rotation={[0.3, 0, 0.1]}>
<group ref={groupRef}>
{chars.map((char, i) => {
const angle = ((i - chars.length / 2) * charWidth * 0.9) / radius;
const x = Math.sin(angle) * radius;
const z = Math.cos(angle) * radius;
return (
<Text
key={i}
position={[x, 0, z]}
rotation={[0, -angle, 0]}
fontSize={fontSize}
font={FONT_URL}
anchorX="center"
anchorY="middle"
>
{char}
<meshStandardMaterial
color="#ffffff"
emissive="#5bd1d8"
emissiveIntensity={0.8}
transparent
opacity={0.95}
side={THREE.DoubleSide}
/>
</Text>
);
})}
</group>
</group>
);
}
/* All circular texts assembled */
function CountdownText() {
return (
<group>
{/* "L'electron" — large ring, slow spin */}
<CircularText
text={"L'\u00e9lectron"}
radius={3.8}
speed={0.15}
tilt={[-0.3, 0, 0]}
fontSize={0.55}
color="#ffffff"
emissive="#ffffff"
spread={0.9}
/>
{/* "rare" — opposite tilt, bigger */}
<CircularText
text="rare"
radius={3.5}
speed={-0.2}
tilt={[0.5, 0.2, 0]}
fontSize={0.85}
color="#ffffff"
emissive="#5bd1d8"
spread={1.0}
/>
{/* Subtitle — wider ring, slow */}
<CircularText
text="SYSTEMES ELECTRONIQUES SPECIFIQUES"
radius={5.2}
speed={0.08}
tilt={[1.3, 0.2, 0.4]}
fontSize={0.11}
color="#ffffff"
emissive="#5bd1d8"
opacity={0.35}
spread={0.7}
/>
{/* "LANCEMENT DANS" — small ring */}
<CircularText
text="LANCEMENT DANS"
radius={3.2}
speed={0.25}
tilt={[-0.7, -0.3, 0.2]}
fontSize={0.1}
color="#5bd1d8"
emissive="#5bd1d8"
opacity={0.5}
spread={0.8}
/>
{/* Countdown timer — spinning ring */}
<CountdownRing />
{/* Extra decorative text rings */}
<CircularText
text="electronique automatisme energie stockage prototypage formation"
radius={5.8}
speed={-0.05}
tilt={[0.8, -0.4, 0.6]}
fontSize={0.07}
color="#5bd1d8"
emissive="#5bd1d8"
opacity={0.2}
spread={0.5}
/>
</group>
);
}
/* ---------- Main scene ---------- */
function AtomScene() {
const groupRef = useRef<THREE.Group>(null);
const orbits = useMemo(() => [
{ tilt: [-0.5, 0, 0], speed: 1.0, color: COLORS.cyan },
{ tilt: [0.5, 0.3, 0], speed: 0.72, color: COLORS.amber },
{ tilt: [1.57, 0.2, 0.4], speed: 0.55, color: COLORS.green },
], []);
useFrame(({ clock, pointer }) => {
if (!groupRef.current) return;
const t = clock.getElapsedTime();
// Mouse distance from center = speed boost
const mouseDist = Math.sqrt(pointer.x ** 2 + pointer.y ** 2);
const speedMul = 1 + mouseDist * 0.8;
groupRef.current.rotation.y = t * 0.12 * speedMul + pointer.x * 0.6;
groupRef.current.rotation.x = -0.2 + pointer.y * 0.4;
groupRef.current.rotation.z = pointer.x * 0.1;
});
return (
<group ref={groupRef}>
<Nucleus />
{orbits.map((o, i) => (
<OrbitRing key={`ring-${i}`} tilt={o.tilt} color={o.color} />
))}
{orbits.map((o, i) => (
<OrbitTrail key={`trail-${i}`} tilt={o.tilt} speed={o.speed} color={o.color} />
))}
<EnergyArc color={COLORS.cyan} />
<EnergyArc color={COLORS.amber} />
<ParticleField />
{/* Textes orbitants — dans le groupe, tournent avec l'atome */}
<CountdownText />
</group>
);
}
/* ---------- Camera with mouse parallax ---------- */
function CameraRig() {
const { camera } = useThree();
const mouse = useRef({ x: 0, y: 0 });
const smooth = useRef({ x: 0, y: 0 });
useFrame(({ clock, pointer }) => {
const t = clock.getElapsedTime();
// Smooth mouse follow (lerp)
mouse.current.x = pointer.x;
mouse.current.y = pointer.y;
smooth.current.x += (mouse.current.x - smooth.current.x) * 0.05;
smooth.current.y += (mouse.current.y - smooth.current.y) * 0.05;
// Camera orbits gently + follows mouse
camera.position.x = Math.sin(t * 0.08) * 0.5 + smooth.current.x * 2.5;
camera.position.y = Math.cos(t * 0.06) * 0.3 + smooth.current.y * 1.5;
camera.position.z = 9 + Math.sin(t * 0.1) * 0.3;
camera.lookAt(smooth.current.x * 0.5, smooth.current.y * 0.3, 0);
});
return null;
}
/* ---------- Exported component ---------- */
export default function AtomGlitch() {
return (
<div
style={{
width: '100vw',
height: '100vh',
}}
aria-label="Atome 3D animé — L'Electron Rare"
role="img"
>
<Canvas
camera={{ position: [0, -0.5, 9], fov: 45 }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
style={{ background: 'transparent' }}
>
<color attach="background" args={['#000000']} />
<fog attach="fog" args={['#000000', 8, 18]} />
<ambientLight intensity={0.15} />
<pointLight position={[5, 3, 5]} intensity={0.8} color="#ffffff" />
<pointLight position={[-4, -2, 3]} intensity={0.4} color="#5bd1d8" />
<pointLight position={[0, 4, -3]} intensity={0.3} color="#f1c27a" />
<CameraRig />
<AtomScene />
<EffectComposer>
<Bloom luminanceThreshold={0.2} luminanceSmoothing={0.9} intensity={1.8} mipmapBlur />
<ChromaticAberration blendFunction={BlendFunction.NORMAL} offset={new THREE.Vector2(0.002, 0.002)} />
<GlitchEffect delay={new THREE.Vector2(3, 8)} duration={new THREE.Vector2(0.1, 0.4)} strength={new THREE.Vector2(0.05, 0.15)} mode={GlitchMode.SPORADIC} />
<Scanline blendFunction={BlendFunction.OVERLAY} density={1.8} opacity={0.05} />
</EffectComposer>
</Canvas>
</div>
);
}
+120 -190
View File
@@ -1,63 +1,42 @@
---
import BaseLayout from '@/layouts/BaseLayout.astro';
import AtomGlitch from '@/components/AtomGlitch';
import '@/styles/global.css';
---
<BaseLayout title="L'Electron Rare — Bientôt" description="L'Electron Rare arrive. Systèmes électroniques spécifiques, formations, prototypage.">
<main class="countdown-page">
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare — logo animé"></div>
<svg class="atom" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-cyan-soft, #5bd1d866)" stroke-width="0.8" transform="rotate(-30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-amber-soft, #f1c27a66)" stroke-width="0.8" transform="rotate(30 150 150)"/>
<ellipse cx="150" cy="150" rx="120" ry="45" fill="none" stroke="var(--trace-green-soft, #b6d18f44)" stroke-width="0.8" transform="rotate(90 150 150)"/>
<circle cx="150" cy="150" r="18" fill="url(#nucleusGlow)"/>
<circle cx="150" cy="150" r="8" fill="var(--accent, #f1c27a)"/>
<circle r="5" fill="var(--electric, #5bd1d8)"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
<circle r="10" fill="var(--electric, #5bd1d8)" opacity="0.3"><animateMotion dur="3s" repeatCount="indefinite"><mpath href="#o1"/></animateMotion></circle>
<circle r="4" fill="var(--accent, #f1c27a)"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
<circle r="9" fill="var(--accent, #f1c27a)" opacity="0.25"><animateMotion dur="4.2s" repeatCount="indefinite"><mpath href="#o2"/></animateMotion></circle>
<circle r="3" fill="var(--trace-green, #b6d18f)"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
<circle r="8" fill="var(--trace-green, #b6d18f)" opacity="0.2"><animateMotion dur="5.8s" repeatCount="indefinite"><mpath href="#o3"/></animateMotion></circle>
<defs>
<ellipse id="o1" cx="150" cy="150" rx="120" ry="45" transform="rotate(-30 150 150)" fill="none"/>
<ellipse id="o2" cx="150" cy="150" rx="120" ry="45" transform="rotate(30 150 150)" fill="none"/>
<ellipse id="o3" cx="150" cy="150" rx="120" ry="45" transform="rotate(90 150 150)" fill="none"/>
<radialGradient id="nucleusGlow">
<stop offset="0%" stop-color="var(--accent, #f1c27a)" stop-opacity="0.6"/>
<stop offset="100%" stop-color="var(--accent, #f1c27a)" stop-opacity="0"/>
</radialGradient>
</defs>
</svg>
<div class="title-bubble">
<h1 class="title" data-text="L'électron rare">L'<span class="electric">é</span>lectron rare</h1>
<!-- WebGL background -->
<div class="webgl-bg">
<AtomGlitch client:load />
</div>
<div class="timer-bubble">
<div class="timer" id="timer">
<div class="unit"><span class="num" id="days">--</span><span class="label">jours</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="hours">--</span><span class="label">heures</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="minutes">--</span><span class="label">minutes</span></div>
<div class="sep">:</div>
<div class="unit"><span class="num" id="seconds">--</span><span class="label">secondes</span></div>
<!-- Overlay content -->
<div class="overlay">
<!-- Top bar -->
<div class="top-bar">
<div class="logo-sprite" id="logo-sprite" aria-label="L'Electron Rare — logo animé"></div>
<span class="brand-mark">L'ELECTRON RARE</span>
</div>
</div>
<div class="tagline-bubble" id="tagline-bubble" aria-live="polite">
<span class="tagline-text" id="tagline"></span>
</div>
<!-- Center — spacer (text is in WebGL) -->
<div class="center-spacer">
<div class="tagline-area" id="tagline-bubble" aria-live="polite">
<span class="tagline-cursor">></span>
<span class="tagline-text" id="tagline"></span>
</div>
</div>
<div class="footnote-bubble">
<span>Systèmes électroniques spécifiques · Formations · Prototypage</span>
<!-- Bottom bar -->
<div class="bottom-bar">
<span class="bottom-left">electronique · automatisme · energie</span>
<span class="bottom-right">mai 2026</span>
</div>
</div>
</main>
</BaseLayout>
<script>
const LAUNCH = new Date('2026-05-01T00:00:00+02:00').getTime();
const messages = [
"Charge des condensateurs en cours...",
"Calibration de l'oscilloscope...",
@@ -77,17 +56,9 @@ import '@/styles/global.css';
];
let msgIndex = Math.floor(Math.random() * messages.length);
function pad(n) { return String(n).padStart(2, '0'); }
function update() {
const diff = Math.max(0, LAUNCH - Date.now());
document.getElementById('days').textContent = pad(Math.floor(diff / 86400000));
document.getElementById('hours').textContent = pad(Math.floor((diff % 86400000) / 3600000));
document.getElementById('minutes').textContent = pad(Math.floor((diff % 3600000) / 60000));
document.getElementById('seconds').textContent = pad(Math.floor((diff % 60000) / 1000));
}
function rotateMessage() {
const bubble = document.getElementById('tagline-bubble');
const el = document.getElementById('tagline');
const bubble = document.getElementById('tagline-bubble')!;
const el = document.getElementById('tagline')!;
bubble.style.opacity = '0';
bubble.style.transform = 'translateY(4px)';
setTimeout(() => {
@@ -98,180 +69,139 @@ import '@/styles/global.css';
}, 400);
}
// Logo sprite animation - cycle through 4 frames
const frames = [
'/assets/brand/logo-frame-1.png',
'/assets/brand/logo-frame-2.png',
'/assets/brand/logo-frame-3.png',
'/assets/brand/logo-frame-4.png',
'/assets/brand/logo-frame-3.png',
'/assets/brand/logo-frame-2.png',
];
let frameIdx = 0;
const sprite = document.getElementById('logo-sprite');
const sprite = document.getElementById('logo-sprite')!;
sprite.style.backgroundImage = `url(/assets/brand/logo-mark.png)`;
// Preload frames
frames.forEach(f => { const img = new Image(); img.src = f; });
function animateLogo() {
sprite.style.opacity = '0.85';
setTimeout(() => {
sprite.style.backgroundImage = `url(${frames[frameIdx]})`;
sprite.style.opacity = '1';
frameIdx = (frameIdx + 1) % frames.length;
}, 150);
}
update(); rotateMessage();
sprite.style.backgroundImage = `url(${frames[0]})`;
setInterval(update, 1000);
rotateMessage();
setInterval(rotateMessage, 8000);
setInterval(animateLogo, 2000);
</script>
<style>
.countdown-page {
position: relative;
min-height: 100vh;
overflow: hidden;
background: #000;
font-family: 'Manrope', -apple-system, 'SF Pro Display', 'Helvetica Neue', sans-serif;
color: #fff;
}
/* WebGL canvas — full background */
.webgl-bg {
position: fixed;
inset: 0;
z-index: 0;
width: 100vw;
height: 100vh;
}
/* Overlay — full viewport, vertical layout */
.overlay {
position: relative;
z-index: 10;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: clamp(32px, 6vw, 80px) clamp(20px, 4vw, 48px);
background-color: #ffffff;
font-family: 'Manrope', -apple-system, 'SF Pro Display', 'Helvetica Neue', sans-serif;
justify-content: space-between;
padding: clamp(20px, 3vw, 40px) clamp(24px, 5vw, 60px);
pointer-events: none;
}
.overlay > * { pointer-events: auto; }
/* Logo — clean, static display */
/* ===== TOP BAR ===== */
.top-bar {
display: flex;
align-items: center;
gap: 16px;
animation: fade-in 0.8s ease both;
}
.logo-sprite {
width: clamp(120px, 18vw, 180px);
height: clamp(120px, 18vw, 180px);
width: 48px;
height: 48px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: clamp(16px, 3vw, 32px);
transition: opacity 0.3s ease;
animation: gentle-float 6s ease-in-out infinite;
filter: brightness(1.2) drop-shadow(0 0 12px rgba(91, 209, 216, 0.4));
}
@keyframes gentle-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
.brand-mark {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.25em;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
}
.atom {
width: clamp(100px, 16vw, 150px);
height: auto;
margin-bottom: clamp(16px, 2vw, 24px);
opacity: 0.12;
}
/* Title bubble */
.title-bubble {
margin-bottom: clamp(24px, 4vw, 40px);
padding: 0;
animation: fade-in 0.8s ease both;
}
.title {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(32px, 6vw, 56px);
font-weight: 800;
color: #1d1d1f;
margin: 0;
letter-spacing: -0.03em;
line-height: 1.1;
}
.electric {
color: #0071e3;
}
/* Timer */
.timer-bubble {
background: #f5f5f7;
border: none;
border-radius: 20px;
padding: clamp(16px, 3vw, 28px) clamp(24px, 4vw, 48px);
margin-bottom: clamp(20px, 3vw, 32px);
animation: fade-in 0.8s 0.2s ease both;
}
.timer {
display: flex;
align-items: center;
gap: clamp(8px, 2vw, 16px);
}
.unit {
/* ===== CENTER SPACER ===== */
.center-spacer {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
.num {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(36px, 8vw, 72px);
font-weight: 300;
color: #1d1d1f;
line-height: 1;
min-width: 2ch;
font-variant-numeric: tabular-nums;
}
.label {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(9px, 1.2vw, 12px);
color: #86868b;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-top: 8px;
font-weight: 500;
}
.sep {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(24px, 5vw, 48px);
color: #d2d2d7;
font-weight: 200;
margin-bottom: 18px;
justify-content: flex-end;
padding-bottom: clamp(20px, 3vw, 40px);
}
/* Tagline */
.tagline-bubble {
padding: 12px 24px;
margin-bottom: clamp(16px, 2vw, 24px);
min-height: 44px;
/* Tagline — terminal style */
.tagline-area {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 10px 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
transition: opacity 0.4s ease, transform 0.4s ease;
animation: fade-in 0.8s 0.4s ease both;
animation: fade-in 0.8s 1.1s ease both;
}
.tagline-cursor {
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 14px;
color: #5bd1d8;
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.tagline-text {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(13px, 1.6vw, 16px);
color: #86868b;
font-weight: 400;
letter-spacing: 0.01em;
}
/* Footnote */
.footnote-bubble {
padding: clamp(8px, 1.5vw, 12px) 20px;
animation: fade-in 0.8s 0.6s ease both;
}
.footnote-bubble span {
font-family: 'Manrope', -apple-system, 'SF Pro Display', sans-serif;
font-size: clamp(11px, 1.2vw, 13px);
color: #aeaeb2;
letter-spacing: 0.02em;
font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
font-size: clamp(11px, 1.3vw, 14px);
color: rgba(255, 255, 255, 0.4);
font-weight: 400;
}
/* Animations */
/* ===== BOTTOM BAR ===== */
.bottom-bar {
display: flex;
justify-content: space-between;
align-items: flex-end;
animation: fade-in 0.8s 1.2s ease both;
}
.bottom-left, .bottom-right {
font-size: clamp(10px, 1.1vw, 12px);
font-weight: 400;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.15);
text-transform: uppercase;
}
/* ===== Animations ===== */
@keyframes fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.timer-hero { gap: 2px; }
.sep { margin: 0; }
.bottom-bar { flex-direction: column; align-items: center; gap: 4px; }
}
@media (prefers-reduced-motion: reduce) {
.logo-sprite { animation: none; }
.title-bubble, .timer-bubble, .tagline-bubble, .footnote-bubble {
animation: none;
opacity: 1;
}
.logo-sprite, .title, .timer-hero, .divider,
.tagline-area, .subtitle, .overline,
.top-bar, .bottom-bar { animation: none; opacity: 1; }
.webgl-bg { display: none; }
}
</style>