c01

CSS Scroll Reveal

Free

Native CSS scroll-driven reveals for crisp fade, slide, and scale entrances with accessible static fallbacks and no animation JavaScript.

beginner
3 more details
scroll-revealscroll-driven-animationprogressive-enhancement
CSS Scroll Reveal - GSAP animation effect preview

This demo runs full window rather than in an embedded frame:

About this effect

A focused native CSS reveal system that binds opacity and transform entrances to an element's progress through the viewport. Five reusable classes cover fade, upward slide, side slides, and scale without using JavaScript for animation. The demo is a short studio page where each class appears once as its block enters, while unsupported and reduced-motion browsers receive the complete settled page.

What's included

8 items
  • Five reusable reveal classes for fade, slide, and scale entrances
  • Native view() timelines with no JavaScript animation dependency
  • Per-element animation ranges through the --reveal-range custom property
  • Compositor-friendly opacity and transform response channels
  • CSS-only accent-rule growth as a secondary channel
  • Complete static fallback when scroll timelines are unsupported
  • Reduced-motion mode that shows the settled page immediately
  • Responsive single-column layout with the same reveals on phones

Perfect for

5 use cases
  • Studio and portfolio pages with lightweight scroll entrances
  • Editorial landing pages that reveal sections as they arrive
  • Static sites where animation JavaScript is intentionally avoided
  • Marketing sections that need progressive enhancement
  • Mobile-first pages with a designed reduced-motion fallback

How it works

1 section

Each reusable class assigns keyframes to a native view() animation timeline, so the browser maps an element's viewport entry directly to opacity or transform progress. The --reveal-range custom property moves the start and finish points without changing the keyframes, which is how the demo staggers its three project cards and lets the last blocks on the page settle before the scroll runs out. @supports and prefers-reduced-motion rules remove the animation and expose the settled page immediately.

Difficulty Beginner
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this effect's demo, 15 September 2026. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title>CSS Scroll Reveal Demo | GSAP Vault</title>
	<link rel="stylesheet" href="assets/style.css">
	<link rel="preconnect" href="https://fonts.googleapis.com">
	<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
	<link href="https://fonts.googleapis.com/css2?family=Mona+Sans:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
	<main class="page">
		<!-- Opening: plain content, no reveal class, so the page is never blank on load. -->
		<section class="intro">
			<p class="masthead"><span>Marrow Studio</span><span>Bristol</span></p>
			<h1 class="headline">Print, identity and the occasional website.</h1>
			<p class="prompt">
				<span class="prompt-dot" aria-hidden="true"></span>
				<strong>Scroll</strong>
				<span>each block reveals as it enters the viewport, in CSS alone</span>
			</p>
		</section>

		<!-- Project index: three cards, each .reveal-slide-up with its own --reveal-range so they arrive in sequence. -->
		<section class="projects" aria-label="Selected projects">
			<article class="card reveal-slide-up">
				<p class="card-index">01</p>
				<h2 class="card-title">Fold Bakery</h2>
				<p class="card-meta">Identity, packaging, signage</p>
			</article>
			<article class="card reveal-slide-up">
				<p class="card-index">02</p>
				<h2 class="card-title">Harbour Quarterly</h2>
				<p class="card-meta">Editorial design, four issues a year</p>
			</article>
			<article class="card reveal-slide-up">
				<p class="card-index">03</p>
				<h2 class="card-title">Tern Cycles</h2>
				<p class="card-meta">Identity and a small website</p>
			</article>
		</section>

		<!-- About: a heading, a CSS-only rule that grows underneath it, and two columns sliding in from either side. -->
		<section class="about" aria-label="About the studio">
			<h2 class="section-title reveal-fade">Four people, one room, no account managers.</h2>
			<div class="accent-rule" aria-hidden="true"></div>
			<p class="about-lead reveal-slide-left">We take on eight or nine projects a year and give each one the same two designers from the first meeting to the final proof.</p>
			<p class="about-body reveal-slide-right">Most of our work is printed. The websites we do build are small, fast and hand-coded, which is also why this page animates without a line of JavaScript.</p>
		</section>

		<!-- Closing: one .reveal-scale block with the only button on the page. -->
		<section class="closing reveal-scale" aria-label="Start a project">
			<p class="closing-text">Starting something this year?</p>
			<a class="btn btn--filled" href="#">Talk to us</a>
		</section>

		<!-- Footer line, .reveal-fade. -->
		<p class="colophon reveal-fade">Marrow Studio, 14 Wapping Wharf, Bristol. Est. 2017.</p>
	</main>

	<script src="assets/script.js"></script>
</body>
</html>
/**
 * CSS Scroll Reveal
 *
 * The reveals are entirely CSS-driven. This optional helper only exposes a
 * support hook so unsupported browsers can render the completed composition.
 */
(function onReady(init) {
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', init);
	} else {
		init();
	}
})(function detectScrollTimelineSupport() {
	'use strict';

	if (!window.CSS || !CSS.supports('animation-timeline', 'view()')) {
		document.documentElement.classList.add('no-scroll-timeline');
	}
});
!function(n){"loading"===document.readyState?document.addEventListener("DOMContentLoaded",n):n()}(function(){"use strict";window.CSS&&CSS.supports("animation-timeline","view()")||document.documentElement.classList.add("no-scroll-timeline")});
/* CSS Scroll Reveal - demo styles.
   A small studio page on a neutral grey ground, one ink, one accent, Mona
   Sans. The reveal classes at the bottom of this file are the reusable API;
   everything above them is only the demo's page and can be replaced. */

* {
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}

:root {
	color-scheme: light;

	--page: #ececeb;
	--surface: #f7f7f6;
	--surface-sunk: #e2e2e0;
	--ink: #141412;
	--ink-dim: rgba(20, 20, 18, 0.72);
	--ink-faint: rgba(20, 20, 18, 0.64);
	--rule: rgba(20, 20, 18, 0.14);
	--rule-strong: rgba(20, 20, 18, 0.3);

	/* One accent: the growing rule, the prompt mark, the one button, the
	   focus ring and ::selection. */
	--accent: #2563eb;
	--accent-ink: #ffffff;

	--font-sans: 'Mona Sans', 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
	--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
	/* Scroll-scrubbed reveals want a gentler curve than a timed tween: with an
	   expo ease the element is 90% settled a third of the way in and the
	   stagger between neighbours vanishes. */
	--ease-reveal: cubic-bezier(0.22, 0.61, 0.36, 1);

	--gutter: clamp(20px, 5vw, 72px);
}

html {
	background: var(--page);
	/* Side slides translate off the page for a moment; clip so they never
	   widen the scroll area on a phone. */
	overflow-x: clip;
}

body {
	min-width: 320px;
	background: var(--page);
	color: var(--ink);
	font-family: var(--font-sans);
	line-height: 1.5;
	overflow-x: clip;
	-webkit-font-smoothing: antialiased;
	-moz-osx-font-smoothing: grayscale;
}

::selection {
	background: var(--accent);
	color: var(--accent-ink);
}

:focus-visible {
	outline: 2px solid var(--accent);
	outline-offset: 3px;
}

/* ============================================
   PAGE
   ============================================ */
.page {
	width: min(100%, 1120px);
	margin: 0 auto;
	padding: 0 var(--gutter) var(--gutter);
}

/* Opening. Sized so the first card row straddles the fold of the product
   iframe and is caught mid-reveal in the very first frame. */
.intro {
	display: grid;
	grid-template-rows: auto minmax(0, 1fr) auto;
	gap: clamp(16px, 3vh, 32px);
	min-height: 79svh;
	padding-top: var(--gutter);
	padding-bottom: clamp(20px, 4vh, 40px);
}

.masthead,
.card-index,
.card-meta,
.colophon {
	font-size: 0.8125rem;
	font-weight: 500;
	line-height: 1.4;
	color: var(--ink-faint);
}

.masthead {
	display: flex;
	justify-content: space-between;
	gap: 1rem;
}

.masthead span:first-child {
	color: var(--ink);
}

.headline {
	align-self: center;
	max-width: 14ch;
	font-size: clamp(2.25rem, 6.4vw, 5rem);
	font-weight: 600;
	line-height: 1;
	letter-spacing: -0.04em;
}

/* The one prompt line. */
.prompt {
	display: flex;
	align-items: center;
	gap: 0.5rem;
	font-size: 0.8125rem;
	line-height: 1.35;
	color: var(--ink-faint);
}

.prompt strong {
	color: var(--ink);
	font-weight: 500;
}

.prompt-dot {
	flex: 0 0 auto;
	width: 0.4rem;
	height: 0.4rem;
	border-radius: 50%;
	background: var(--accent);
}

/* ============================================
   PROJECT INDEX
   ============================================ */
.projects {
	display: grid;
	grid-template-columns: repeat(3, minmax(0, 1fr));
	gap: clamp(12px, 1.6vw, 20px);
}

.card {
	display: grid;
	gap: 0.5rem;
	padding: clamp(18px, 2vw, 28px);
	background: var(--surface);
	border: 1px solid var(--rule);
	border-radius: 10px;
}

.card-title {
	margin-top: clamp(14px, 2.4vh, 28px);
	font-size: clamp(1.375rem, 2.4vw, 1.875rem);
	font-weight: 600;
	line-height: 1.05;
	letter-spacing: -0.03em;
}

/* Three ranges so the cards arrive one after another instead of as a block.
   These only tune where each reveal starts and settles. */
.projects .card:nth-child(1) { --reveal-range: entry 0% cover 34%; }
.projects .card:nth-child(2) { --reveal-range: entry 6% cover 40%; }
.projects .card:nth-child(3) { --reveal-range: entry 12% cover 46%; }

/* ============================================
   ABOUT
   ============================================ */
.about {
	display: grid;
	grid-template-columns: repeat(2, minmax(0, 1fr));
	column-gap: clamp(24px, 4vw, 64px);
	row-gap: clamp(20px, 3vh, 32px);
	/* Each block gets most of a screen to itself. A scroll-driven reveal is
	   only legible when the visitor scrolls it into view; with the sections
	   packed tight the whole page was 750px of runway inside the product
	   frame, so one wheel flick finished the demo and the page took over. */
	padding-top: clamp(96px, 52svh, 420px);
}

.section-title {
	grid-column: 1 / -1;
	max-width: 20ch;
	font-size: clamp(1.75rem, 4vw, 3rem);
	font-weight: 600;
	line-height: 1.05;
	letter-spacing: -0.035em;
}

/* The secondary channel: a rule that draws itself as it enters. Pure CSS,
   documented in the README. */
.accent-rule {
	grid-column: 1 / -1;
	height: 3px;
	background: var(--accent);
	transform-origin: left center;
	animation: rule-grow auto var(--ease-reveal) both;
	animation-timeline: view();
	animation-range: entry 10% cover 35%;
}

.about-lead {
	font-size: clamp(1.25rem, 2.2vw, 1.75rem);
	font-weight: 500;
	line-height: 1.25;
	letter-spacing: -0.02em;
}

.about-body {
	max-width: 42ch;
	font-size: clamp(1rem, 1.3vw, 1.125rem);
	color: var(--ink-dim);
}

/* ============================================
   CLOSING + COLOPHON
   ============================================ */
.closing {
	display: flex;
	align-items: center;
	justify-content: space-between;
	gap: 1.5rem;
	flex-wrap: wrap;
	margin-top: clamp(96px, 48svh, 400px);
	padding: clamp(24px, 3vw, 40px);
	background: var(--surface);
	border: 1px solid var(--rule);
	border-radius: 10px;
}

/* The last blocks on a page can never travel 40% of the viewport before the
   scroll runs out, so their ranges finish sooner. */
.closing { --reveal-range: entry 0% cover 28%; }
.colophon { --reveal-range: entry 0% entry 100%; }

.closing-text {
	font-size: clamp(1.375rem, 2.6vw, 2rem);
	font-weight: 600;
	line-height: 1.1;
	letter-spacing: -0.03em;
}

.btn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	padding: 0.7rem 1.25rem;
	font: inherit;
	font-size: 0.9375rem;
	font-weight: 500;
	color: var(--ink);
	text-decoration: none;
	background: transparent;
	border: 1px solid var(--rule-strong);
	border-radius: 6px;
	cursor: pointer;
	transition: background-color 0.2s var(--ease-out-expo), border-color 0.2s var(--ease-out-expo);
}

.btn--filled {
	color: var(--accent-ink);
	background: var(--accent);
	border-color: var(--accent);
}

.btn--filled:hover,
.btn--filled:focus-visible {
	background: color-mix(in srgb, var(--accent) 85%, #000);
	border-color: color-mix(in srgb, var(--accent) 85%, #000);
}

.colophon {
	margin-top: clamp(64px, 34svh, 300px);
	padding-top: 1rem;
	border-top: 1px solid var(--rule);
}

/* ============================================
   THE REVEAL API
   Copy from here down into your own stylesheet. Add one class to any element;
   set --reveal-range on it to change where the reveal starts and settles.
   ============================================ */
.reveal-fade,
.reveal-slide-up,
.reveal-scale,
.reveal-slide-left,
.reveal-slide-right {
	animation-duration: auto;
	animation-timing-function: var(--ease-reveal, cubic-bezier(0.22, 0.61, 0.36, 1));
	animation-fill-mode: both;
	animation-timeline: view();
	animation-range: var(--reveal-range, entry 0% cover 40%);
	will-change: opacity, transform;
}

.reveal-fade { animation-name: fade-in; }
.reveal-slide-up { animation-name: slide-up; }
.reveal-scale { animation-name: scale-in; }
.reveal-slide-left { animation-name: slide-left; }
.reveal-slide-right { animation-name: slide-right; }

@keyframes fade-in {
	from { opacity: 0; }
	to { opacity: 1; }
}

@keyframes slide-up {
	from { opacity: 0; transform: translateY(4rem); }
	to { opacity: 1; transform: translateY(0); }
}

@keyframes scale-in {
	from { opacity: 0; transform: scale(0.72); }
	to { opacity: 1; transform: scale(1); }
}

@keyframes slide-left {
	from { opacity: 0; transform: translateX(-4rem); }
	to { opacity: 1; transform: translateX(0); }
}

@keyframes slide-right {
	from { opacity: 0; transform: translateX(4rem); }
	to { opacity: 1; transform: translateX(0); }
}

@keyframes rule-grow {
	from { opacity: 0; transform: scaleX(0); }
	to { opacity: 1; transform: scaleX(1); }
}

/* ============================================
   RESPONSIVE
   ============================================ */
@media (max-width: 720px) {
	.intro {
		grid-template-rows: auto auto auto;
		gap: 28px;
		min-height: 0;
		padding-bottom: 28px;
	}

	.projects,
	.about {
		grid-template-columns: minmax(0, 1fr);
	}

	.projects .card:nth-child(n) { --reveal-range: entry 0% cover 34%; }

	.prompt span:last-child {
		display: none;
	}
}

/* ============================================
   REDUCED MOTION + FALLBACK
   Reduced motion and browsers without scroll-driven animations both get the
   settled page: every reveal visible, nothing moving.
   ============================================ */
@media (prefers-reduced-motion: reduce) {
	*,
	*::before,
	*::after {
		animation-duration: 0.01ms !important;
		animation-delay: 0ms !important;
		transition-duration: 0.01ms !important;
		scroll-behavior: auto !important;
	}

	.accent-rule,
	.reveal-fade,
	.reveal-slide-up,
	.reveal-scale,
	.reveal-slide-left,
	.reveal-slide-right {
		animation: none !important;
		opacity: 1 !important;
		transform: none !important;
	}
}

@supports not (animation-timeline: view()) {
	.accent-rule,
	.reveal-fade,
	.reveal-slide-up,
	.reveal-scale,
	.reveal-slide-left,
	.reveal-slide-right {
		animation: none;
		opacity: 1;
		transform: none;
	}
}

/* Same fallback, applied by the optional script for browsers whose @supports
   answer is wrong. */
.no-scroll-timeline .accent-rule,
.no-scroll-timeline .reveal-fade,
.no-scroll-timeline .reveal-slide-up,
.no-scroll-timeline .reveal-scale,
.no-scroll-timeline .reveal-slide-left,
.no-scroll-timeline .reveal-slide-right {
	animation: none;
	opacity: 1;
	transform: none;
	will-change: auto;
}

Native CSS scroll-driven entrance classes for opacity, translate, and scale. Animation requires no JavaScript; the optional script only adds an unsupported-browser fallback hook.

Quick Start

1. Copy the reveal classes and keyframes from assets/style.css into your stylesheet. They sit under the THE REVEAL API heading, together with the reduced-motion and @supports fallback blocks below it.

2. Add a reveal class to an element in your <body>:

<article class="reveal-slide-up">
  This fades and rises as it enters the viewport.
</article>

3. Optionally tune that element's range in your CSS:

.feature-card {
  --reveal-range: entry 10% cover 35%;
}

No animation script or GSAP CDN tag is required.

Using It With Your Own Design

What the effect requires of your markup: one of the five .reveal-* classes on any block-level element. Nothing else: no wrapper, no data attributes, no particular parent. The element animates against its own position in the nearest scroll container (view()), so it works inside any layout.

What is only the demo's CSS: everything above the THE REVEAL API heading in assets/style.css (the studio page, its cards, the .about grid, the button, the colours and Mona Sans) is the demo and can be deleted. The .projects .card:nth-child() ranges and the .closing and .colophon ranges are examples of tuning --reveal-range, not part of the API.

Non-obvious CSS the effect depends on:

  • overflow-x: clip on html and body. The side slides translate an element 4rem off the page for a moment; without the clip a phone gains a horizontal scroll area.
  • --ease-reveal (or any timing function you set on the classes). Scroll-scrubbed reveals want a gentler curve than a timed tween: with an expo ease the element is 90% settled a third of the way in and any stagger between neighbours disappears.
  • Blocks at the very end of a page never reach cover 40%, because the document stops scrolling first, so they stay partly transparent. Give them a shorter range: the demo uses entry 0% cover 28% on its closing block and entry 0% entry 100% on the footer line.
  • Keep the @media (prefers-reduced-motion: reduce) and @supports not (animation-timeline: view()) blocks, and the .no-scroll-timeline rules if you ship the optional script. Together they are what makes an unsupported or reduced-motion browser show the settled page instead of a blank one.

Options

Class / property Values Default Description
.reveal-fade Class Reveals with opacity only
.reveal-slide-up Class Fades and translates upward from 4rem below
.reveal-scale Class Fades and scales from 0.72
.reveal-slide-left Class Fades while entering from the left
.reveal-slide-right Class Fades while entering from the right
--reveal-range Any valid animation-range entry 0% cover 40% Controls where the reveal starts and settles

Every element in the demo uses view(), its own progress through the viewport. Nothing is bound to the root scroll.

Examples

A staggered row of cards

Three cards in one row enter the viewport together, so give each a later range and they arrive one after another:

Add to your HTML <body>:

<div class="card-row">
  <article class="reveal-slide-up">First</article>
  <article class="reveal-slide-up">Second</article>
  <article class="reveal-slide-up">Third</article>
</div>

Add to your stylesheet:

.card-row > :nth-child(1) { --reveal-range: entry 0% cover 34%; }
.card-row > :nth-child(2) { --reveal-range: entry 6% cover 40%; }
.card-row > :nth-child(3) { --reveal-range: entry 12% cover 46%; }

Accent-rule growth

The demo's secondary response, a rule that draws itself under a heading, is also CSS-driven:

.accent-rule {
  transform-origin: left;
  animation: rule-grow auto ease-out both;
  animation-timeline: view();
  animation-range: entry 10% cover 35%;
}

@keyframes rule-grow {
  from { opacity: 0; transform: scaleX(0); }
  to { opacity: 1; transform: scaleX(1); }
}

Browser Support and Fallback

Native scroll-driven animations are supported in current Chromium-based browsers and Safari releases. Check Can I Use for current versions.

The stylesheet includes an @supports not (animation-timeline: view()) branch that removes animation and shows the final composition. assets/script.js is optional: it performs the same support check and adds .no-scroll-timeline for older browsers whose CSS feature detection is inconsistent. It never drives animation.

Without JavaScript, supported browsers animate normally and unsupported browsers receive the complete static content through CSS.

Accessibility

A prefers-reduced-motion: reduce media query disables every reveal and presents the settled content immediately. Keep meaningful content in normal HTML rather than pseudo-elements so it remains available to assistive technology and all fallback modes.

Dependencies

None. The effect uses native CSS only. The demo loads Mona Sans from Google Fonts for its page styling, but the reveal API does not depend on it.

Your cart

Your cart is empty

The Vault £99

The Vault library, plus future additions to the library.