Use scroll animation to support the order of a page, while keeping content visible without JavaScript. A small reveal on a supporting card can help establish sequence; hiding the main headline until a library loads makes the page more fragile. Respect reduced-motion preferences and avoid making visitors wait for essential information.
A working reveal with no animation dependency
Example setup: Tailwind CSS 4.1.18, compiled locally, with plain HTML and vanilla JavaScript. These small educational examples are separate from the membership library. Adapt them to your application and test your final content and browser requirements.
Complete HTML, CSS, and JavaScript
Use your compiled Tailwind stylesheet in place of /examples/tailwind.css. The example uses Tailwind CSS 4 utilities and vanilla JavaScript.
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,follow"><title>Scroll reveals with progressive enhancement</title><link rel="stylesheet" href="/examples/tailwind.css"><style>body{font-family:system-ui,sans-serif;background:#faf9f6;color:#252824;padding:24px}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}:focus-visible{outline:3px solid #4f46e5;outline-offset:4px}</style></head><body><h1 class="text-2xl font-semibold">A little movement. A clear story.</h1><p class="mt-2 mb-8 text-stone-600">Scroll inside this example. Content stays readable without JavaScript.</p>
<div class="space-y-8"><section class="reveal rounded-2xl bg-orange-100 p-8"><h2 class="text-xl font-semibold">Start with the idea</h2><p>Make the main message easy to find.</p></section><section class="reveal rounded-2xl bg-indigo-100 p-8"><h2 class="text-xl font-semibold">Show the details</h2><p>Use movement to support the hierarchy.</p></section><section class="reveal rounded-2xl bg-emerald-100 p-8"><h2 class="text-xl font-semibold">Give it room</h2><p>Leave essential content visible.</p></section></div><script>const preference = matchMedia('(prefers-reduced-motion: reduce)');
const played = new WeakSet();
const animations = new Set();
if ('IntersectionObserver' in window && Element.prototype.animate) {
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting || played.has(entry.target)) continue;
played.add(entry.target);
observer.unobserve(entry.target);
if (preference.matches) continue;
const animation = entry.target.animate([{ transform: 'translateY(16px)', opacity: 0.5 }, { transform: 'translateY(0)', opacity: 1 }], { duration: 350, easing: 'ease-out' });
animations.add(animation);
animation.onfinish = () => animations.delete(animation);
}
}, { threshold: 0.15 });
document.querySelectorAll('.reveal').forEach(element => observer.observe(element));
preference.addEventListener('change', () => { if (preference.matches) { animations.forEach(animation => animation.cancel()); animations.clear(); } });
}</script></body></html>The example uses IntersectionObserver to detect entry and the browser’s animation API to animate each section once. Tailwind provides the layout and visual styles. Content is visible by default, so unsupported APIs or failed JavaScript leave a readable page. Turning on reduced motion cancels active animations.
When Motion is useful
For a project that already uses Motion, its inView and animate functions can express a similar reveal. Install the motion package in your own project and import it from a JavaScript entry point handled by your bundler. Do not paste a bare package import into a plain HTML script.
import { animate, inView } from 'motion';
const preference = matchMedia('(prefers-reduced-motion: reduce)');
const active = new Set();
const stop = inView('.reveal', element => {
if (preference.matches) return;
const animation = animate(element,
{ opacity: [0.5, 1], y: [16, 0] },
{ duration: 0.35, ease: 'easeOut' });
active.add(animation);
animation.then(() => active.delete(animation));
});
const onPreferenceChange = () => {
if (preference.matches) {
active.forEach(animation => animation.complete());
active.clear();
}
};
preference.addEventListener('change', onPreferenceChange);
// On component/page teardown:
// stop();
// preference.removeEventListener('change', onPreferenceChange);
// active.forEach(animation => animation.complete());
The embedded runnable demo uses native browser APIs; the Motion fragment illustrates an alternative integration. Check Motion’s current inView API and your framework’s cleanup lifecycle before using it.
Choose what moves
| Content | Suggested approach | Reason |
|---|---|---|
| Main heading and primary action | Visible immediately | Visitors should not wait to understand the page |
| Supporting cards | Small, one-time reveal | A restrained transition can clarify grouping |
| Long article text | Usually static | Reading benefits from stable content |
| Automatically rotating content | Avoid unless needed; provide controls | Motion should not take away reading time |
Keep the page stable
Animate transforms and opacity rather than repeatedly changing the document layout. Reserve image dimensions. Do not stagger a long list so heavily that the final item remains unavailable while someone scrolls. If you animate words individually, keep a coherent accessible text representation instead of making screen readers encounter fragments.
Test the fallback, not only the effect
- Disable JavaScript and verify the article remains readable.
- Enable reduced motion before loading, then change it while the page is open.
- Scroll quickly and check that no sections remain hidden.
- Use keyboard navigation to reach links inside animated sections.
- Check a slow device with real images and real content.
Compare this with the carousel guide before adding more movement. Explore feature layouts for sections that work well even when every animation is removed.
