Scroll reveals animate content into view as the user scrolls down the page. At Verdigris, they serve the orientation purpose: they signal "new content is arriving" and establish visual hierarchy. The www codebase uses CSS keyframe animations triggered by Intersection Observer, not JS animation libraries.
Use a single observer instance per page. Observe elements with a shared class, and toggle a visibility class when they enter the viewport.
// Recommended: one observer, many targets
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target); // animate once, then stop observing
}
});
},
{
threshold: 0.1, // trigger when 10% visible
rootMargin: '0px 0px -50px 0px', // start slightly before element reaches viewport bottom
}
);
// Observe all reveal targets
document.querySelectorAll('.reveal').forEach((el) => observer.observe(el));| Parameter | Value | Why |
|---|---|---|
threshold |
0.1 |
Triggers early enough that the animation completes before the user reaches the element |
rootMargin |
0px 0px -50px 0px |
Adds a 50px buffer at the bottom so elements animate in slightly before they hit the viewport edge |
unobserve after trigger |
Yes | Animate once. Re-triggering on scroll-back feels jittery and wastes CPU |
import { useEffect, useRef, useState } from 'react';
function useScrollReveal<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Respect reduced-motion: show immediately
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.unobserve(el);
}
},
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return { ref, isVisible };
}Usage:
function FeatureCard({ title, description }: Props) {
const { ref, isVisible } = useScrollReveal<HTMLDivElement>();
return (
<div
ref={ref}
className={`reveal ${isVisible ? 'is-visible' : ''}`}
>
<h3>{title}</h3>
<p>{description}</p>
</div>
);
}All reveals use duration.slow (500ms) + easing.out (ease-out). The element starts in its "hidden" state and transitions to its "visible" state.
The simplest reveal. Content fades from transparent to opaque.
.reveal-fade {
opacity: 0;
transition: opacity 500ms ease-out; /* duration.slow + easing.out */
}
.reveal-fade.is-visible {
opacity: 1;
}The primary reveal pattern at Verdigris. Used on the www hero and section headings. Content slides up 30px while fading in.
/* From www index.css */
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.5s ease-out both; /* duration.slow + easing.out */
}For scroll-triggered (non-hero) usage with the Intersection Observer pattern:
.reveal-slide-up {
opacity: 0;
transform: translateY(30px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.reveal-slide-up.is-visible {
opacity: 1;
transform: translateY(0);
}Content scales from slightly smaller to full size. Use for cards, images, and contained elements. Avoid on text-only blocks (scaling text feels wrong).
.reveal-scale {
opacity: 0;
transform: scale(0.95);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.reveal-scale.is-visible {
opacity: 1;
transform: scale(1);
}Use sparingly for split-layout sections where left and right content should feel like they're coming together.
.reveal-slide-left {
opacity: 0;
transform: translateX(-30px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.reveal-slide-left.is-visible {
opacity: 1;
transform: translateX(0);
}
.reveal-slide-right {
opacity: 0;
transform: translateX(30px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
}
.reveal-slide-right.is-visible {
opacity: 1;
transform: translateX(0);
}When revealing a group of items (card grid, feature list, stat row), stagger the reveal so items animate in sequence rather than all at once.
Use --stagger-index set on each item to calculate a progressive delay:
.reveal-stagger {
opacity: 0;
transform: translateY(20px);
transition: opacity 500ms ease-out, transform 500ms ease-out;
transition-delay: calc(var(--stagger-index, 0) * 100ms);
}
.reveal-stagger.is-visible {
opacity: 1;
transform: translateY(0);
}<div class="grid">
<div class="reveal-stagger" style="--stagger-index: 0">Card 1</div>
<div class="reveal-stagger" style="--stagger-index: 1">Card 2</div>
<div class="reveal-stagger" style="--stagger-index: 2">Card 3</div>
<div class="reveal-stagger" style="--stagger-index: 3">Card 4</div>
</div>{% raw %}
function StaggerGrid({ items }: { items: Item[] }) {
const { ref, isVisible } = useScrollReveal<HTMLDivElement>();
return (
<div ref={ref} className="grid grid-cols-3 gap-6">
{items.map((item, i) => (
<div
key={item.id}
className={`reveal-stagger ${isVisible ? 'is-visible' : ''}`}
style={{ '--stagger-index': i } as React.CSSProperties}
>
<Card {...item} />
</div>
))}
</div>
);
}{% endraw %}
| Rule | Value | Why |
|---|---|---|
| Delay per item | 100ms | Fast enough to feel sequential, slow enough to perceive the wave |
| Maximum total stagger | 400ms (4 items visible) | Beyond 400ms total delay, users lose patience |
| Cap visible items | Stagger only the first 4-6 items in view | Items below the fold don't need stagger |
| Minimum group size | 3 items | Staggering 2 items looks like a bug, not a pattern |
If a grid has more than 6 items visible at once, stagger the first 4-6 and reveal the rest simultaneously.
The hero is a special case: it animates on page load, not on scroll. Use the CSS animation property (not transitions triggered by class toggle) so it runs immediately.
/* From www index.css — this is the canonical hero pattern */
@keyframes slide-up {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.5s ease-out both;
}For staggered hero elements (heading, then subtitle, then CTA):
.hero-heading { animation: slide-up 0.5s ease-out both; }
.hero-subtitle { animation: slide-up 0.5s ease-out 0.1s both; }
.hero-cta { animation: slide-up 0.5s ease-out 0.2s both; }All scroll reveals must show content immediately when reduced-motion is active. Never leave content invisible.
@media (prefers-reduced-motion: reduce) {
.animate-slide-up {
animation: none;
opacity: 1;
}
.reveal-fade,
.reveal-slide-up,
.reveal-scale,
.reveal-slide-left,
.reveal-slide-right,
.reveal-stagger {
opacity: 1;
transform: none;
transition: none;
}
}The React hook shown above checks prefers-reduced-motion and sets isVisible = true immediately, skipping the observer entirely.
See reduced-motion.md for the full reduced-motion guide.
-
Do: Unobserve elements after they animate in. One-shot reveals are less jarring and more performant than re-triggering.
-
Do: Use
bothas theanimation-fill-modefor keyframe animations. This ensures the element stays in its final state after the animation completes. -
Do: Keep translate distances small (20-30px). Large translate distances (100px+) feel like the content is flying in from off-screen, which is disorienting.
-
Do: Use the same reveal pattern consistently within a page. Mixing slide-up, scale-in, and fade on the same page creates visual noise.
-
Don't: Stagger more than 6 items. Long stagger chains make users wait to see content they've already scrolled to.
-
Don't: Animate content that is above the fold on initial load via Intersection Observer. Above-the-fold content should either be immediately visible or use the hero entrance pattern (CSS animation, no observer).
-
Don't: Re-trigger reveals when scrolling back up. The
unobservepattern ensures one-shot behavior. -
Don't: Use
translateYdistances greater than 40px. Larger distances make the animation feel heavy and slow. -
Don't: Forget to set
opacity: 1in the reduced-motion fallback. Without it, content using reveal classes stays invisible for users with motion preferences.
- guidelines.md — Three gates, duration/easing decision trees
- reduced-motion.md — Full accessibility fallback guide
- hover-states.md — Interaction-triggered animations
- foundations/motion.md — Token rationale