// Дополнительные интерактивы: кастомный курсор, magnetic-кнопка, hover-карточка с tilt, // бегущая строка-маркер, число-счётчик при появлении. // Кастомный курсор — два кольца, ловят hover на интерактивных элементах function CustomCursor() { const dot = React.useRef(); const ring = React.useRef(); const [hover, setHover] = React.useState(false); const [label, setLabel] = React.useState(''); React.useEffect(() => { let x=0,y=0,rx=0,ry=0,raf; const onMove = (e) => { x = e.clientX; y = e.clientY; const t = e.target.closest('[data-cursor]'); setHover(!!t); setLabel(t ? (t.getAttribute('data-cursor') || '') : ''); }; const tick = () => { rx += (x - rx) * 0.18; ry += (y - ry) * 0.18; if (dot.current) dot.current.style.transform = `translate(${x-3}px,${y-3}px)`; if (ring.current) ring.current.style.transform = `translate(${rx-18}px,${ry-18}px) scale(${hover?1.6:1})`; raf = requestAnimationFrame(tick); }; window.addEventListener('mousemove', onMove); raf = requestAnimationFrame(tick); return () => { window.removeEventListener('mousemove', onMove); cancelAnimationFrame(raf); }; }, [hover]); return ( <>
{hover && label && (
{label}
)}
); } // Магнитная кнопка — притягивается к курсору function MagnetBtn({ children, onClick, style }) { const ref = React.useRef(); const onMove = (e) => { const r = ref.current.getBoundingClientRect(); const x = e.clientX - (r.left + r.width/2); const y = e.clientY - (r.top + r.height/2); ref.current.style.transform = `translate(${x*0.25}px, ${y*0.4}px)`; }; const onLeave = () => { ref.current.style.transform = 'translate(0,0)'; }; return ( ); } // Tilt-карточка function TiltCard({ children, style, label }) { const ref = React.useRef(); const onMove = (e) => { const r = ref.current.getBoundingClientRect(); const x = (e.clientX - r.left)/r.width - 0.5; const y = (e.clientY - r.top)/r.height - 0.5; ref.current.style.transform = `perspective(900px) rotateY(${x*8}deg) rotateX(${-y*8}deg) translateY(-4px)`; }; const onLeave = () => { ref.current.style.transform = 'perspective(900px) rotateY(0) rotateX(0) translateY(0)'; }; return (
{children}
); } // Бегущая строка function Marquee({ items, speed = 60 }) { const arr = [...items, ...items, ...items]; return (
{arr.map((it,i) => ( {it} ))}
); } // Счётчик-число (анимируется при появлении) function CountUp({ to, suffix='', dur=1400 }) { const [v, setV] = React.useState(0); const ref = React.useRef(); const started = React.useRef(false); React.useEffect(() => { const io = new IntersectionObserver((es) => { es.forEach(e => { if (e.isIntersecting && !started.current) { started.current = true; const t0 = performance.now(); const tick = () => { const p = Math.min(1, (performance.now()-t0)/dur); const eased = 1 - Math.pow(1-p, 3); setV(Math.round(to * eased)); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); } }); }, { threshold: 0.5 }); if (ref.current) io.observe(ref.current); return () => io.disconnect(); }, [to, dur]); return {v}{suffix}; } // Hover-выделение строки услуг (раскрытие справа описания) function ServiceRow({ n, title, desc, price }) { const [open, setOpen] = React.useState(false); return (
setOpen(true)} onMouseLeave={()=>setOpen(false)} data-cursor="узнать" style={{ display:'grid', gridTemplateColumns:'70px 1fr auto', alignItems:'center', padding:'24px 4px', borderTop:'1.5px solid var(--ink)', cursor:'pointer', background: open ? 'var(--accent)' : 'transparent', color: open ? 'var(--bg)' : 'var(--ink)', transition:'background .25s, color .25s, padding .25s', paddingLeft: open ? 24 : 4 }}>
№ 0{n}
{title}
{desc}
{price}
+
); } Object.assign(window, { CustomCursor, MagnetBtn, TiltCard, Marquee, CountUp, ServiceRow });