hero-v4: perspective tunnel — cards on angled walls, radial speed lines

Forward motion (not sideways):
- Tunnel walls: rotateY(12°) left, rotateY(-12°) right
- 10 KPI cards slide down walls with perspective scale (smaller = farther)
- Cards reset when off-screen, pick new random data
- 50 radial lines emanating from center outward
- 80 starfield dots scattering from center
- Center: clean — just headline + CTA, no data
- Goo glow expands & brightens with speed
- Hover = accelerate forward (CRUISE 0.5 → BOOST 2.5)

Metaphor: driving into a data tunnel, cards on walls rush past
This commit is contained in:
neo committed 2026-06-21 21:24:52 +05:00
1 parent 9ba79075a9
commit 2605318513
1 file changed
+503
+503
View File
@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ROI24 — Perspective Hero</title>
<link rel="preconnect" href="https://fonts.cdnfonts.com">
<link href="https://fonts.cdnfonts.com/css/inter" rel="stylesheet">
<link href="https://fonts.cdnfonts.com/css/jetbrains-mono" rel="stylesheet">
<style>
/* ============================================================
DESIGN TOKENS
============================================================ */
:root {
--bg: #0a0a0a;
--text: #f5f5f5;
--text-sec: #a1a1aa;
--text-dim: #52525b;
--accent: #22c55e;
--accent-dim: rgba(34,197,94,0.12);
--blue: #3b82f6;
--blue-dim: rgba(59,130,246,0.12);
--amber: #f59e0b;
--red: #ef4444;
--font: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'SF Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg); color: var(--text); font-family: var(--font);
overflow: hidden; height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* ============================================================
PERSPECTIVE STAGE — the tunnel
============================================================ */
.stage {
position: relative; width: 100%; height: 100vh;
overflow: hidden;
perspective: 1000px;
perspective-origin: 50% 50%;
}
/* ============================================================
VISUAL EFFECTS LAYER
============================================================ */
.fx-layer {
position: absolute; inset: 0; pointer-events: none; z-index: 1;
}
/* Noise texture */
.fx-layer::before {
content: ''; position: absolute; inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02'/%3E%3C/svg%3E");
opacity: 0.5;
}
/* Goo — ambient light ahead */
.goo {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 500px; height: 500px; border-radius: 50%;
background: radial-gradient(circle,
rgba(34,197,94,0.10) 0%,
rgba(34,197,94,0.03) 35%,
transparent 65%
);
transition: transform 0.5s ease, opacity 0.5s ease;
}
/* ============================================================
TUNNEL WALLS — cards on left & right with 3D perspective
============================================================ */
.wall {
position: absolute; top: 50%; z-index: 20;
transform-style: preserve-3d;
pointer-events: none;
}
.wall-left {
left: 4%;
transform: translateY(-50%) rotateY(12deg);
transform-origin: right center;
}
.wall-right {
right: 4%;
transform: translateY(-50%) rotateY(-12deg);
transform-origin: left center;
}
/* Each card is a "panel" on the tunnel wall */
.tunnel-card {
background: rgba(22,22,24,0.88);
backdrop-filter: blur(6px);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
padding: 12px 14px;
width: 170px;
margin-bottom: 16px;
transition: opacity 0.4s ease, filter 0.4s ease;
}
.tunnel-card .tl {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.07em;
color: var(--text-dim); font-weight: 600; margin-bottom: 3px;
}
.tunnel-card .tv {
font-family: var(--mono); font-size: 17px; font-weight: 700;
line-height: 1.15; margin-bottom: 2px;
}
.tunnel-card .tv.g { color: var(--accent); }
.tunnel-card .tv.b { color: var(--blue); }
.tunnel-card .tv.a { color: var(--amber); }
.tunnel-card .td {
display: inline-flex; align-items: center; gap: 2px;
padding: 2px 5px; border-radius: 3px;
font-family: var(--mono); font-weight: 600; font-size: 9px;
}
.tunnel-card .td.up { color: var(--accent); background: var(--accent-dim); }
.tunnel-card .td.down { color: var(--red); background: rgba(239,68,68,0.12); }
/* ── RADIAL LINES ── lines from center outward */
.radial-line {
position: absolute; top: 50%; left: 50%;
height: 1px;
background: linear-gradient(90deg,
rgba(255,255,255,0.08) 0%,
rgba(34,197,94,0.2) 30%,
rgba(34,197,94,0.05) 60%,
transparent 100%
);
transform-origin: left center;
}
.radial-line.fast {
background: linear-gradient(90deg,
rgba(255,255,255,0.15) 0%,
rgba(34,197,94,0.35) 25%,
rgba(34,197,94,0.08) 55%,
transparent 100%
);
height: 1.5px;
}
/* ── DOTS — starfield particles ── */
.dot {
position: absolute; border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 4px var(--accent);
}
/* ============================================================
CENTER — clean, no data, just headline
============================================================ */
.center {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
text-align: center; z-index: 30; max-width: 560px;
width: 100%; padding: 0 24px;
}
.badge {
display: inline-flex; align-items: center; gap: 8px;
padding: 4px 14px; border: 1px solid rgba(255,255,255,0.1);
border-radius: 100px; margin-bottom: 20px;
font-size: 11px; color: var(--text-sec); font-family: var(--mono);
background: rgba(255,255,255,0.02);
}
.badge-dot {
width: 5px; height: 5px; background: var(--accent); border-radius: 50%;
animation: dPulse 1.5s ease-in-out infinite;
}
@keyframes dPulse {
0%,100% { box-shadow: 0 0 3px var(--accent); }
50% { box-shadow: 0 0 10px var(--accent); }
}
.center h1 {
font-size: clamp(1.8rem, 5vw, 4rem);
font-weight: 800; letter-spacing: -0.04em;
line-height: 1.08; margin-bottom: 16px;
}
.center h1 .accent { color: var(--accent); }
.center .sub {
font-size: 1rem; color: rgba(255,255,255,0.4);
line-height: 1.6; margin-bottom: 32px;
}
.btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
padding: 14px 28px; border-radius: 100px; font-weight: 600;
font-size: 0.9rem; cursor: pointer; border: none;
transition: all 0.25s ease; text-decoration: none;
}
.btn-accent {
background: var(--accent); color: #000;
}
.btn-accent:hover {
background: #16a34a; transform: translateY(-1px);
box-shadow: 0 12px 40px rgba(34,197,94,0.25);
}
.btn-outline {
background: transparent; border: 1px solid rgba(255,255,255,0.2);
color: var(--text);
}
.btn-outline:hover {
border-color: rgba(255,255,255,0.4);
background: rgba(255,255,255,0.03);
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 768px) {
.wall-left { left: 1%; }
.wall-right { right: 1%; }
.tunnel-card { width: 140px; padding: 10px 12px; }
.tunnel-card .tv { font-size: 15px; }
}
@media (max-width: 480px) {
.tunnel-card { width: 110px; padding: 8px 10px; }
.tunnel-card .tv { font-size: 13px; }
.tunnel-card .tl { font-size: 8px; }
}
</style>
</head>
<body>
<div class="stage" id="stage">
<!-- FX LAYER -->
<div class="fx-layer" id="fxLayer">
<div class="goo" id="goo"></div>
</div>
<!-- TUNNEL WALLS -->
<div class="wall wall-left" id="wallLeft"></div>
<div class="wall wall-right" id="wallRight"></div>
<!-- CENTER -->
<div class="center">
<div class="badge">
<span class="badge-dot"></span>
АНАЛИТИКА ДЛЯ СОБСТВЕННИКОВ
</div>
<h1>Живые цифры<br>вашего <span class="accent">бизнеса</span></h1>
<p class="sub">
10 ключевых метрик из 1С, CRM и банков —<br>
автоматически, каждое утро в телефоне
</p>
<div class="btns">
<a href="#cta" class="btn btn-accent"
onmouseenter="setSpeed(1)"
onmouseleave="setSpeed(0)">
Получить демо за 3 дня
</a>
<a href="#demo" class="btn btn-outline"
onmouseenter="setSpeed(1)"
onmouseleave="setSpeed(0)">
Посмотреть пример
</a>
</div>
</div>
</div>
<script>
/* ============================================================
PERSPECTIVE TUNNEL ENGINE
Cards on walls slide as we "drive forward"
Radial lines from center outward
Dots scatter like starfield
============================================================ */
const stage = document.getElementById('stage');
const wallLeft = document.getElementById('wallLeft');
const wallRight = document.getElementById('wallRight');
const fxLayer = document.getElementById('fxLayer');
const goo = document.getElementById('goo');
// ── SPEED STATE ──
let targetSpeed = 0;
let currentSpeed = 0; // lerped: 0=cruise, 1=boost
const CRUISE = 0.5; // always some motion
const BOOST = 2.5;
// ── CARD POOL ──
const CARD_DATA = [
{ label: 'Выручка за 30 дн', value: '2 051 857 ₽', trend: '+15%', clr: 'g', dir: 'up', wall: 'left' },
{ label: 'CPA средний', value: '890 ₽', trend: '−5%', clr: 'b', dir: 'down', wall: 'left' },
{ label: 'Продажи', value: '1 247 шт', trend: '+12%', clr: 'b', dir: 'up', wall: 'left' },
{ label: 'ROI рекламы', value: '91.8%', trend: '+5%', clr: 'a', dir: 'up', wall: 'right' },
{ label: 'Прибыль', value: '981 857 ₽', trend: '+12%', clr: 'g', dir: 'up', wall: 'right' },
{ label: 'Конверсия', value: '2.8%', trend: '+0.3%',clr: 'a', dir: 'up', wall: 'right' },
];
// Track each card instance on the wall
let wallCards = []; // { el, wall, offset, speedM, opacity }
function spawnCard(data, offsetY) {
const div = document.createElement('div');
div.className = 'tunnel-card';
div.innerHTML = `
<div class="tl">${data.label}</div>
<div class="tv ${data.clr}">${data.value}</div>
<span class="td ${data.dir}">${data.dir === 'up' ? '↑' : '↓'} ${data.trend}</span>
`;
const wall = data.wall === 'left' ? wallLeft : wallRight;
wall.appendChild(div);
return { el: div, wall: data.wall, offset: offsetY, speedM: 0.8 + Math.random() * 1.2 };
}
// Initialize walls with staggered cards
function initWalls() {
wallLeft.innerHTML = '';
wallRight.innerHTML = '';
wallCards = [];
const leftData = CARD_DATA.filter(d => d.wall === 'left');
const rightData = CARD_DATA.filter(d => d.wall === 'right');
// Stagger vertically: some visible, some ready to slide in
leftData.forEach((d, i) => {
const y = -60 + i * 80 + Math.random() * 20;
wallCards.push(spawnCard(d, y));
});
rightData.forEach((d, i) => {
const y = -40 + i * 80 + Math.random() * 20;
wallCards.push(spawnCard(d, y));
});
// Add extra "flying" cards for depth
for (let i = 0; i < 4; i++) {
const side = Math.random() < 0.5 ? 'left' : 'right';
const data = CARD_DATA[Math.floor(Math.random() * CARD_DATA.length)];
const y = -150 - Math.random() * 100;
wallCards.push(spawnCard({ ...data, wall: side }, y));
}
}
// ── RADIAL LINES ──
let radialLines = []; // { el, angle, length, speedM, life }
function spawnRadialLine() {
const div = document.createElement('div');
const isFast = Math.random() < 0.2;
div.className = isFast ? 'radial-line fast' : 'radial-line';
const angle = Math.random() * 360;
const length = 30 + Math.random() * 120;
div.style.width = length + 'px';
div.style.transform = `rotate(${angle}deg) translateY(-50%)`;
div.style.opacity = '0';
fxLayer.appendChild(div);
return { el: div, angle, length, speedM: 0.3 + Math.random() * 1.0, life: 0 };
}
// Initialize radial lines
for (let i = 0; i < 50; i++) {
radialLines.push(spawnRadialLine());
}
// ── DOTS (starfield) ──
let dots = []; // { el, x, y, vx, vy, speedM, life }
function spawnDot() {
const div = document.createElement('div');
div.className = 'dot';
const size = 2 + Math.random() * 2;
div.style.width = size + 'px';
div.style.height = size + 'px';
// Start at center
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
div.style.left = cx + 'px';
div.style.top = cy + 'px';
div.style.opacity = '0';
fxLayer.appendChild(div);
const angle = Math.random() * Math.PI * 2;
return {
el: div, x: cx, y: cy,
vx: Math.cos(angle), vy: Math.sin(angle),
speedM: 0.5 + Math.random() * 2.0,
life: 0, maxLife: 60 + Math.random() * 120, // frames
};
}
for (let i = 0; i < 80; i++) {
dots.push(spawnDot());
}
// ── LOOP ──
function loop() {
const lerp = 0.04;
currentSpeed += (CRUISE + targetSpeed * BOOST - currentSpeed) * lerp;
const spd = currentSpeed; // shorthand
const vw = window.innerWidth;
const vh = window.innerHeight;
const cx = vw / 2;
const cy = vh / 2;
// ── RADIAL LINES ──
radialLines.forEach(line => {
line.life += spd * line.speedM * 1.5;
const dist = line.life;
const alpha = dist < 10 ? dist / 10 : Math.max(0, 1 - (dist - 10) / 80);
line.el.style.opacity = alpha;
line.el.style.transform = `rotate(${line.angle}deg) translateX(${dist}px)`;
// Reset
if (dist > 90) {
line.life = 0;
line.angle = Math.random() * 360;
line.el.style.transform = `rotate(${line.angle}deg) translateX(0px)`;
}
});
// ── DOTS (starfield) ──
dots.forEach(dot => {
dot.life += spd * dot.speedM;
dot.x += dot.vx * spd * dot.speedM * 2;
dot.y += dot.vy * spd * dot.speedM * 2;
dot.el.style.left = dot.x + 'px';
dot.el.style.top = dot.y + 'px';
const dx = dot.x - cx;
const dy = dot.y - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
// Fade in near center, bright at mid distance, fade out at edges
let alpha;
if (dist < 20) alpha = dist / 20 * 0.6;
else if (dist < vw * 0.8) alpha = 0.3 + 0.3 * Math.sin(dist * 0.01);
else alpha = Math.max(0, (vw - dist) / (vw * 0.2) * 0.3);
dot.el.style.opacity = alpha;
// Reset when too far
if (dist > vw * 0.7) {
dot.x = cx + (Math.random() - 0.5) * 20;
dot.y = cy + (Math.random() - 0.5) * 20;
const a = Math.random() * Math.PI * 2;
dot.vx = Math.cos(a); dot.vy = Math.sin(a);
dot.life = 0;
}
});
// ── TUNNEL CARDS — slide down (as if we're moving forward, walls scroll past) ──
wallCards.forEach(card => {
card.offset += spd * card.speedM * 0.8;
card.el.style.transform = `translateY(${card.offset}px)`;
// Cards drifting up into the distance or passing below
// Apply scale: farther away = smaller
const distFromCenter = card.offset; // positive = below center (passing by)
const absDist = Math.abs(distFromCenter);
const scale = Math.max(0.3, 1 - absDist / 600);
const opacity = Math.max(0.15, 1 - absDist / 500);
card.el.style.opacity = opacity;
card.el.style.transform += ` scale(${scale})`;
// Reset: when card scrolls off bottom, spawn new at top
if (card.offset > vh * 0.7) {
card.offset = -100 - Math.random() * 200;
// Pick random new data
const d = CARD_DATA[Math.floor(Math.random() * CARD_DATA.length)];
card.el.querySelector('.tl').textContent = d.label;
card.el.querySelector('.tv').textContent = d.value;
card.el.querySelector('.tv').className = 'tv ' + d.clr;
card.el.querySelector('.td').textContent = (d.dir === 'up' ? '↑ ' : '↓ ') + d.trend;
card.el.querySelector('.td').className = 'td ' + d.dir;
}
});
// ── GOO — glow responds to speed ──
const glowScale = 1 + spd * 0.2;
const glowOpacity = 0.5 + spd * 0.5;
goo.style.transform = `translate(-50%, -50%) scale(${glowScale})`;
goo.style.opacity = glowOpacity;
requestAnimationFrame(loop);
}
// ── SPEED CONTROL ──
function setSpeed(active) {
targetSpeed = active ? 1 : 0;
}
// ── INIT ──
initWalls();
requestAnimationFrame(loop);
window.addEventListener('resize', () => {
initWalls();
});
</script>
</body>
</html>