Web Animations Easy
Scroll Fade In
Smooth fade-in animation triggered by Intersection Observer as elements enter the viewport on scroll.
Open in Lab
MCP
css js intersection-observer vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #f1f5f9;
line-height: 1.6;
}
.hero {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
}
.hero h1 {
font-size: clamp(2rem, 5vw, 4rem);
font-weight: 700;
margin-bottom: 1rem;
background: linear-gradient(135deg, #38bdf8, #818cf8);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.content {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 1rem;
padding: 2rem;
}
.card h2 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: #38bdf8;
}
/* --- Fade-in animation --- */
.fade-in-el {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}
.fade-in-el.is-visible {
opacity: 1;
transform: translateY(0);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.fade-in-el {
opacity: 1;
transform: none;
transition: none;
}
}// Scroll Fade In — Intersection Observer
(function () {
"use strict";
const elements = document.querySelectorAll(".fade-in-el");
if (!elements.length) return;
// Skip animation if user prefers reduced motion
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
elements.forEach((el) => el.classList.add("is-visible"));
return;
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
// Unobserve once visible — no need to keep watching
obs.unobserve(entry.target);
});
},
{
threshold: 0.15, // trigger when 15% of the element is visible
rootMargin: "0px 0px -40px 0px", // slight offset from viewport bottom
}
);
elements.forEach((el) => observer.observe(el));
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scroll Fade In</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<section class="hero">
<h1>Scroll Fade In</h1>
<p>Scroll down to see elements fade in.</p>
</section>
<main class="content">
<div class="card fade-in-el">
<h2>Card One</h2>
<p>This card fades in when it enters the viewport.</p>
</div>
<div class="card fade-in-el">
<h2>Card Two</h2>
<p>Each card fades in independently as you scroll.</p>
</div>
<div class="card fade-in-el">
<h2>Card Three</h2>
<p>Powered by the native Intersection Observer API.</p>
</div>
<div class="card fade-in-el">
<h2>Card Four</h2>
<p>No JavaScript libraries required — just CSS + vanilla JS.</p>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useRef, useState } from "react";
interface FadeInProps {
children: React.ReactNode;
className?: string;
threshold?: number;
delay?: number;
}
function FadeIn({ children, className = "", threshold = 0.15, delay = 0 }: FadeInProps) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
// Respect reduced motion
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
setVisible(true);
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.unobserve(el);
}
},
{ threshold, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transition: `opacity 0.6s ease-out ${delay}ms, transform 0.6s ease-out ${delay}ms`,
}}
>
{children}
</div>
);
}
// Demo usage
export default function ScrollFadeDemo() {
const cards = [
{ title: "Card One", body: "This card fades in when it enters the viewport." },
{ title: "Card Two", body: "Each card fades in independently as you scroll." },
{ title: "Card Three", body: "Powered by the native Intersection Observer API." },
{ title: "Card Four", body: "No libraries required — just React + CSS." },
];
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans">
{/* Hero */}
<section className="min-h-[60vh] flex flex-col items-center justify-center text-center p-8">
<h1 className="text-4xl md:text-6xl font-bold mb-4 bg-gradient-to-r from-sky-400 to-indigo-400 bg-clip-text text-transparent">
Scroll Fade In
</h1>
<p className="text-slate-400 text-lg">Scroll down to see elements fade in.</p>
</section>
{/* Cards */}
<div className="max-w-2xl mx-auto p-8 flex flex-col gap-8">
{cards.map((card, i) => (
<FadeIn key={card.title} delay={i * 100}>
<div className="bg-slate-800 border border-slate-700 rounded-2xl p-8">
<h2 className="text-sky-400 font-semibold text-xl mb-2">{card.title}</h2>
<p className="text-slate-300">{card.body}</p>
</div>
</FadeIn>
))}
</div>
</div>
);
}<script setup>
const vFadeIn = {
mounted(el, binding) {
const { delay = 0, threshold = 0.15 } = binding.value ?? {};
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
el.style.opacity = "0";
el.style.transform = "translateY(24px)";
el.style.transition = `opacity 0.6s ease-out ${delay}ms, transform 0.6s ease-out ${delay}ms`;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.style.opacity = "1";
el.style.transform = "translateY(0)";
observer.unobserve(el);
}
},
{ threshold, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
},
};
const cards = [
{ title: "Card One", body: "This card fades in when it enters the viewport." },
{ title: "Card Two", body: "Each card fades in independently as you scroll." },
{ title: "Card Three", body: "Powered by the native Intersection Observer API." },
{ title: "Card Four", body: "No libraries required — just Vue + CSS." },
];
</script>
<template>
<div style="min-height: 100vh; background: #0f172a; color: #f1f5f9; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6;">
<section style="min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 2rem;">
<h1 style="font-size: clamp(2rem, 5vw, 4rem); font-weight: 700; margin-bottom: 1rem; background: linear-gradient(135deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
Scroll Fade In
</h1>
<p style="color: #94a3b8; font-size: 1.125rem;">Scroll down to see elements fade in.</p>
</section>
<div style="max-width: 720px; margin: 0 auto; padding: 2rem; display: flex; flex-direction: column; gap: 2rem;">
<div
v-for="(card, i) in cards"
:key="card.title"
v-fade-in="{ delay: i * 100 }"
style="background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 2rem;"
>
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: #38bdf8;">{{ card.title }}</h2>
<p>{{ card.body }}</p>
</div>
</div>
</div>
</template><script>
const cards = [
{ title: "Card One", body: "This card fades in when it enters the viewport." },
{ title: "Card Two", body: "Each card fades in independently as you scroll." },
{ title: "Card Three", body: "Powered by the native Intersection Observer API." },
{ title: "Card Four", body: "No libraries required — just Svelte + CSS." },
];
function fadeIn(el, { delay = 0, threshold = 0.15 } = {}) {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
el.style.opacity = "0";
el.style.transform = "translateY(24px)";
el.style.transition = `opacity 0.6s ease-out ${delay}ms, transform 0.6s ease-out ${delay}ms`;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.style.opacity = "1";
el.style.transform = "translateY(0)";
observer.unobserve(el);
}
},
{ threshold, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return { destroy() { observer.disconnect(); } };
}
</script>
<div style="min-height: 100vh; background: #0f172a; color: #f1f5f9; font-family: system-ui, -apple-system, sans-serif; line-height: 1.6;">
<section style="min-height: 60vh; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 2rem;">
<h1 style="font-size: clamp(2rem, 5vw, 4rem); font-weight: 700; margin-bottom: 1rem; background: linear-gradient(135deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;">
Scroll Fade In
</h1>
<p style="color: #94a3b8; font-size: 1.125rem;">Scroll down to see elements fade in.</p>
</section>
<div style="max-width: 720px; margin: 0 auto; padding: 2rem; display: flex; flex-direction: column; gap: 2rem;">
{#each cards as card, i}
<div use:fadeIn={{ delay: i * 100 }} style="background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 2rem;">
<h2 style="font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; color: #38bdf8;">{card.title}</h2>
<p>{card.body}</p>
</div>
{/each}
</div>
</div>Scroll Fade In
A lightweight scroll-triggered fade-in animation using the native Intersection Observer API — no libraries required.
How it works
Elements start invisible (opacity: 0, slightly translated down) and fade in once they enter the viewport. The observer disconnects after the element is visible, keeping things performant.
When to use it
- Hero sections with staggered content
- Blog post cards loading as you scroll
- Feature grids on landing pages
Tech used
IntersectionObserver— native browser API, zero dependencies- CSS transitions — smooth, GPU-accelerated
Accessibility
Respects prefers-reduced-motion by skipping the animation for users who prefer it.