b1c09aa53f
- ArticleCard: реальные обложки с fallback на детерминированный градиент по id статьи - HeroBackground: 3 анимированных blob'а + dot-grid + плавный fade к контенту - Stats компонент: 4 карточки — статьи / минуты чтения / токены / просмотры - Reveal компонент: IntersectionObserver-based fade-in при скролле, respect prefers-reduced-motion - next.config: rewrites /uploads/* → engine, чтобы картинки работали с относительными путями - На странице статьи — обложка над контентом
51 lines
1.5 KiB
JavaScript
51 lines
1.5 KiB
JavaScript
'use client';
|
|
import { useEffect, useRef } from 'react';
|
|
|
|
/**
|
|
* Lightweight reveal-on-scroll. Без зависимостей, нативный IntersectionObserver.
|
|
* Дочерние элементы с классом `.reveal` плавно появятся.
|
|
*/
|
|
export default function Reveal({ children, className = '' }) {
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!ref.current) return;
|
|
const io = new IntersectionObserver(
|
|
entries => {
|
|
entries.forEach(e => {
|
|
if (e.isIntersecting) {
|
|
e.target.classList.add('is-visible');
|
|
io.unobserve(e.target);
|
|
}
|
|
});
|
|
},
|
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
|
);
|
|
ref.current.querySelectorAll('.reveal').forEach(el => io.observe(el));
|
|
return () => io.disconnect();
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref} className={className}>
|
|
<style jsx global>{`
|
|
.reveal {
|
|
opacity: 0;
|
|
transform: translateY(20px);
|
|
transition: opacity 0.7s ease-out, transform 0.7s ease-out;
|
|
}
|
|
.reveal.is-visible {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
.reveal-1 { transition-delay: 0.05s; }
|
|
.reveal-2 { transition-delay: 0.15s; }
|
|
.reveal-3 { transition-delay: 0.25s; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.reveal { opacity: 1; transform: none; transition: none; }
|
|
}
|
|
`}</style>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|