9 CSS Parallax Scrolling — No JS, Scroll-Driven
CSS parallax scrolling is scroll-linked motion — a background, a layer, a whole hero — driven entirely by animation-timeline: scroll(root), with no scroll
01 parallax-scroll-bg-slow
02 parallax-scroll-layer-3-depth
03 parallax-scroll-text-over-image
04 parallax-scroll-hero-shrink
05 parallax-scroll-horizontal-timeline
06 parallax-scroll-depth-cards
07 parallax-scroll-sticky-parallax
08 parallax-scroll-mouse-parallax
09 parallax-scroll-zoom-out-scroll
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Slow-moving background
- 02 3-layer depth parallax
- 03 Text drifts over image
- 04 Hero shrink parallax
- 05 Horizontal timeline parallax
- 06 Layered depth cards
- 07 Sticky + parallax combo
- 08 Mouse-move parallax
- 09 Scroll zoom-out
Watch instead of scroll on this page: none of the demos below have a real scrollbar, so each one fakes the effect with a two-second loop running back and forth inside a fixed, non-scrollable iframe. The order below moves from a single drifting layer toward genuine staging. Items 01 through 04 change one property at a time, and 05 through 07 combine that same scroll(root) timeline with sticky positioning, a horizontal axis, and stacked layers. Items 08 and 09 are the idea-form entries, swapping the scroll trigger for a cursor and opening on a close-up that zooms out. For the whole family of things scroll can drive beyond speed differences, the CSS scroll animations are the founding set, and when 07's pinning moves up to the header, the CSS sticky header effects take that slot.
01Slow-moving background
What you see
A single background layer drifts upward at roughly 0.4x the page's own scroll speed, so it lags behind the foreground title and opens a gap the eye reads as depth.

<main class="stage is-demo">
<div class="bgslow">
<div class="bgslow__bg" aria-hidden="true"></div>
<h2 class="bgslow__title">Only the background slows down</h2>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 (값은 토큰에서 받는다) ──────
$shift: 24px; // 배경 이동량 (스크롤의 0.4배 느낌)
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-cream; // 배경 점 색 (ink 무대라 cream)
@include stage($stage-ink);
.bgslow {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
&__bg {
position: absolute;
inset: -20% -10%;
background:
radial-gradient($color 0 46px, transparent 47px) 24% 30%,
radial-gradient($color 0 30px, transparent 31px) 78% 62%,
radial-gradient($color 0 20px, transparent 21px) 55% 82%;
background-repeat: no-repeat;
opacity: .5;
// ── 실제 사용 코드: 배경 하나만 스크롤보다 느리게(0.4배) 움직인다 ──
@supports (animation-timeline: scroll()) {
animation: drift linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) {
transform: none;
}
}
&__title {
position: relative;
z-index: 1;
margin: 0;
padding: 14px 22px;
border-radius: $r-pill;
background: $subject-blue;
color: #fff;
font: 700 clamp(15px, 5vw, 19px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
}
@keyframes drift { from { transform: translateY(0); } to { transform: translateY(-#{$shift}); } }
// ── 데모(.is-demo): 스크롤 대신 왕복 루프로 흉내 ────────
.stage.is-demo .bgslow__bg {
animation: drift-loop $duration ease-in-out infinite;
}
@keyframes drift-loop {
0%, 100% { transform: translateY($shift); }
50% { transform: translateY(-$shift); }
}
// ── 접근성: 움직임 줄이기 설정이면 배경을 그대로 고정 ────────────
@media (prefers-reduced-motion: reduce) {
.bgslow__bg, .stage.is-demo .bgslow__bg { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bgslow {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
}
.bgslow__bg {
position: absolute;
inset: -20% -10%;
background: radial-gradient(#fff7e6 0 46px, transparent 47px) 24% 30%, radial-gradient(#fff7e6 0 30px, transparent 31px) 78% 62%, radial-gradient(#fff7e6 0 20px, transparent 21px) 55% 82%;
background-repeat: no-repeat;
opacity: 0.5;
}
@supports (animation-timeline: scroll()) {
.bgslow__bg {
animation: drift linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.bgslow__bg {
transform: none;
}
}
.bgslow__title {
position: relative;
z-index: 1;
margin: 0;
padding: 14px 22px;
border-radius: 999px;
background: #2f6df6;
color: #fff;
font: 700 clamp(15px, 5vw, 19px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes drift {
from {
transform: translateY(0);
}
to {
transform: translateY(-24px);
}
}
.stage.is-demo .bgslow__bg {
animation: drift-loop 2s ease-in-out infinite;
}
@keyframes drift-loop {
0%, 100% {
transform: translateY(24px);
}
50% {
transform: translateY(-24px);
}
}
@media (prefers-reduced-motion: reduce) {
.bgslow__bg, .stage.is-demo .bgslow__bg {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./SlowBackground.module.scss";
export type SlowBackgroundProps = {
children: ReactNode;
/** 배경 이동량 (기본 24px, 스크롤의 0.4배 느낌) */
shift?: number;
/** 배경 점 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--shift"?: string; "--color"?: string };
export default function SlowBackground({
children,
shift = 24,
color = "#fff7e6",
className = "",
}: SlowBackgroundProps) {
const vars: Vars = { "--shift": `${shift}px`, "--color": color };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.bg} aria-hidden="true" />
<div className={styles.title}>{children}</div>
</div>
);
}01 · Slow-moving background — Measured area 3.371% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css parallax scrolling effect fits
Reach for it on a landing hero background or a banner sitting right under the nav — anywhere a full parallax rig would be overkill for one moving layer.
.bgslow__bg {
position: absolute;
inset: -20% -10%;
opacity: .5;
@supports (animation-timeline: scroll()) {
animation: drift linear both;
animation-timeline: scroll(root);
}
}
@keyframes drift {
from { transform: translateY(0); }
to { transform: translateY(-24px); }
}
023-layer depth parallax
The motion
Three circles sit back, mid, and front, and animation-timeline: scroll(root) drives each one through its own keyframes at 10px, 26px, and 46px of total travel.

<main class="stage is-demo">
<div class="layer3">
<div class="layer3__back" aria-hidden="true"></div>
<div class="layer3__mid" aria-hidden="true"></div>
<div class="layer3__front" aria-hidden="true"></div>
<h2 class="layer3__title">3-layer perspective</h2>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$back-shift: 10px; // 뒤 레이어 이동량 (가장 적게)
$mid-shift: 26px; // 중간 레이어 이동량
$front-shift: 46px; // 앞 레이어 이동량 (가장 많이)
@include stage($stage-yellow);
.layer3 {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
&__back, &__mid, &__front {
position: absolute;
border-radius: 50%;
background: $subject-ink;
}
&__back {
width: 70px; height: 70px; left: 14%; top: 20%; opacity: .18;
@supports (animation-timeline: scroll()) { animation: drift-back linear both; animation-timeline: scroll(root); }
}
&__mid {
width: 56px; height: 56px; left: 58%; top: 46%; opacity: .38;
@supports (animation-timeline: scroll()) { animation: drift-mid linear both; animation-timeline: scroll(root); }
}
&__front {
width: 40px; height: 40px; left: 30%; top: 66%; opacity: .82;
@supports (animation-timeline: scroll()) { animation: drift-front linear both; animation-timeline: scroll(root); }
}
&__title {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
padding: 14px 22px;
border-radius: $r-pill;
background: $stage-ink;
color: #fff;
font: 700 clamp(14px, 4.6vw, 18px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
white-space: nowrap;
}
}
// ── 실제 사용 코드: 세 레이어가 각각 다른 속도로 스크롤과 함께 이동한다 ──
@keyframes drift-back { from { transform: translateY(0); } to { transform: translateY(-#{$back-shift}); } }
@keyframes drift-mid { from { transform: translateY(0); } to { transform: translateY(-#{$mid-shift}); } }
@keyframes drift-front { from { transform: translateY(0); } to { transform: translateY(-#{$front-shift}); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .layer3__back { animation: loop-back $dur-loop ease-in-out infinite; }
.stage.is-demo .layer3__mid { animation: loop-mid $dur-loop ease-in-out infinite; }
.stage.is-demo .layer3__front { animation: loop-front $dur-loop ease-in-out infinite; }
@keyframes loop-back { 0%, 100% { transform: translateY($back-shift); } 50% { transform: translateY(-$back-shift); } }
@keyframes loop-mid { 0%, 100% { transform: translateY($mid-shift); } 50% { transform: translateY(-$mid-shift); } }
@keyframes loop-front { 0%, 100% { transform: translateY($front-shift); } 50% { transform: translateY(-$front-shift); } }
@media (prefers-reduced-motion: reduce) {
.layer3__back, .layer3__mid, .layer3__front,
.stage.is-demo .layer3__back, .stage.is-demo .layer3__mid, .stage.is-demo .layer3__front {
animation: none !important;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.layer3 {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.layer3__back, .layer3__mid, .layer3__front {
position: absolute;
border-radius: 50%;
background: #17141a;
}
.layer3__back {
width: 70px;
height: 70px;
left: 14%;
top: 20%;
opacity: 0.18;
}
@supports (animation-timeline: scroll()) {
.layer3__back {
animation: drift-back linear both;
animation-timeline: scroll(root);
}
}
.layer3__mid {
width: 56px;
height: 56px;
left: 58%;
top: 46%;
opacity: 0.38;
}
@supports (animation-timeline: scroll()) {
.layer3__mid {
animation: drift-mid linear both;
animation-timeline: scroll(root);
}
}
.layer3__front {
width: 40px;
height: 40px;
left: 30%;
top: 66%;
opacity: 0.82;
}
@supports (animation-timeline: scroll()) {
.layer3__front {
animation: drift-front linear both;
animation-timeline: scroll(root);
}
}
.layer3__title {
position: absolute;
z-index: 1;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
padding: 14px 22px;
border-radius: 999px;
background: #17141a;
color: #fff;
font: 700 clamp(14px, 4.6vw, 18px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
white-space: nowrap;
}
@keyframes drift-back {
from {
transform: translateY(0);
}
to {
transform: translateY(-10px);
}
}
@keyframes drift-mid {
from {
transform: translateY(0);
}
to {
transform: translateY(-26px);
}
}
@keyframes drift-front {
from {
transform: translateY(0);
}
to {
transform: translateY(-46px);
}
}
.stage.is-demo .layer3__back {
animation: loop-back 2s ease-in-out infinite;
}
.stage.is-demo .layer3__mid {
animation: loop-mid 2s ease-in-out infinite;
}
.stage.is-demo .layer3__front {
animation: loop-front 2s ease-in-out infinite;
}
@keyframes loop-back {
0%, 100% {
transform: translateY(10px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes loop-mid {
0%, 100% {
transform: translateY(26px);
}
50% {
transform: translateY(-26px);
}
}
@keyframes loop-front {
0%, 100% {
transform: translateY(46px);
}
50% {
transform: translateY(-46px);
}
}
@media (prefers-reduced-motion: reduce) {
.layer3__back, .layer3__mid, .layer3__front,
.stage.is-demo .layer3__back, .stage.is-demo .layer3__mid, .stage.is-demo .layer3__front {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ThreeLayerDepth.module.scss";
export type ThreeLayerDepthProps = {
children: ReactNode;
/** 뒤 레이어 이동량 (기본 10px) */
backShift?: number;
/** 중간 레이어 이동량 (기본 26px) */
midShift?: number;
/** 앞 레이어 이동량 (기본 46px) */
frontShift?: number;
className?: string;
};
type Vars = CSSProperties & {
"--back-shift"?: string;
"--mid-shift"?: string;
"--front-shift"?: string;
};
export default function ThreeLayerDepth({
children,
backShift = 10,
midShift = 26,
frontShift = 46,
className = "",
}: ThreeLayerDepthProps) {
const vars: Vars = {
"--back-shift": `${backShift}px`,
"--mid-shift": `${midShift}px`,
"--front-shift": `${frontShift}px`,
};
return (
<div className={`${styles.layer3} ${className}`} style={vars}>
<div className={styles.back} aria-hidden="true" />
<div className={styles.mid} aria-hidden="true" />
<div className={styles.front} aria-hidden="true" />
<div className={styles.title}>{children}</div>
</div>
);
}02 · 3-layer depth parallax — Measured area 7.535% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
The front layer moves nearly five times as far as the back one, which is what actually reads as perspective rather than three flat circles scrolling in sync. Use the full three-layer version on a game showcase or a brand-story section where one flat layer would look thin.
.layer3__back { animation: drift-back linear both; animation-timeline: scroll(root); }
.layer3__mid { animation: drift-mid linear both; animation-timeline: scroll(root); }
.layer3__front { animation: drift-front linear both; animation-timeline: scroll(root); }
@keyframes drift-back { to { transform: translateY(-10px); } }
@keyframes drift-mid { to { transform: translateY(-26px); } }
@keyframes drift-front { to { transform: translateY(-46px); } }
03Text drifts over image
What it does
The photo itself barely moves — only 14px of total travel — while the caption above it rises 70px over the same scroll distance, so the two layers visibly separate instead of tracking together.

<main class="stage is-demo">
<div class="textimg">
<div class="textimg__image" aria-hidden="true"></div>
<h2 class="textimg__text">A caption that passes by</h2>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$text-shift: 70px; // 글자 이동량 (이미지보다 훨씬 빠르게)
$image-shift: 14px; // 이미지 이동량 (거의 고정)
$color: $subject-cream; // 글자 색 (orange 무대라 cream)
@include stage($stage-orange);
.textimg {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
&__image {
position: absolute;
inset: 14% 10%;
border-radius: $r-card;
background: rgba($subject-ink, .34);
// ── 실제 사용 코드: 이미지는 거의 고정, 글자만 훨씬 빠르게 지나간다 ──
@supports (animation-timeline: scroll()) {
animation: image-drift linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) { transform: none; }
}
&__text {
position: relative;
z-index: 1;
margin: 0;
color: $color;
font: 800 clamp(28px, 11vw, 44px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
mix-blend-mode: difference;
@supports (animation-timeline: scroll()) {
animation: text-drift linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) { transform: none; }
}
}
@keyframes image-drift { from { transform: translateY(0); } to { transform: translateY(-#{$image-shift}); } }
@keyframes text-drift { from { transform: translateY(60px); } to { transform: translateY(-#{$text-shift}); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .textimg__image { animation: image-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .textimg__text { animation: text-loop $dur-loop ease-in-out infinite; }
@keyframes image-loop { 0%, 100% { transform: translateY($image-shift); } 50% { transform: translateY(-$image-shift); } }
@keyframes text-loop { 0%, 100% { transform: translateY(46px); } 50% { transform: translateY(-46px); } }
@media (prefers-reduced-motion: reduce) {
.textimg__image, .textimg__text,
.stage.is-demo .textimg__image, .stage.is-demo .textimg__text {
animation: none !important;
transform: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.textimg {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: hidden;
}
.textimg__image {
position: absolute;
inset: 14% 10%;
border-radius: 16px;
background: rgba(23, 20, 26, 0.34);
}
@supports (animation-timeline: scroll()) {
.textimg__image {
animation: image-drift linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.textimg__image {
transform: none;
}
}
.textimg__text {
position: relative;
z-index: 1;
margin: 0;
color: #fff7e6;
font: 800 clamp(28px, 11vw, 44px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
mix-blend-mode: difference;
}
@supports (animation-timeline: scroll()) {
.textimg__text {
animation: text-drift linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.textimg__text {
transform: none;
}
}
@keyframes image-drift {
from {
transform: translateY(0);
}
to {
transform: translateY(-14px);
}
}
@keyframes text-drift {
from {
transform: translateY(60px);
}
to {
transform: translateY(-70px);
}
}
.stage.is-demo .textimg__image {
animation: image-loop 2s ease-in-out infinite;
}
.stage.is-demo .textimg__text {
animation: text-loop 2s ease-in-out infinite;
}
@keyframes image-loop {
0%, 100% {
transform: translateY(14px);
}
50% {
transform: translateY(-14px);
}
}
@keyframes text-loop {
0%, 100% {
transform: translateY(46px);
}
50% {
transform: translateY(-46px);
}
}
@media (prefers-reduced-motion: reduce) {
.textimg__image, .textimg__text,
.stage.is-demo .textimg__image, .stage.is-demo .textimg__text {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./TextOverImage.module.scss";
export type TextOverImageProps = {
children: ReactNode;
/** 글자 이동량 (기본 70px, 이미지보다 훨씬 빠르게) */
textShift?: number;
/** 이미지 이동량 (기본 14px, 거의 고정) */
imageShift?: number;
/** 글자 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & {
"--text-shift"?: string;
"--image-shift"?: string;
"--color"?: string;
};
export default function TextOverImage({
children,
textShift = 70,
imageShift = 14,
color = "#fff7e6",
className = "",
}: TextOverImageProps) {
const vars: Vars = {
"--text-shift": `${textShift}px`,
"--image-shift": `${imageShift}px`,
"--color": color,
};
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.image} aria-hidden="true" />
<h2 className={styles.text}>{children}</h2>
</div>
);
}03 · Text drifts over image — Measured area 31.153% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
mix-blend-mode: difference keeps the caption legible no matter what color sits underneath it as it slides. It suits an editorial-style hero or a photo-essay title where the headline is meant to feel like it cuts through the image rather than just sitting on it.
.textimg__image {
animation: image-drift linear both;
animation-timeline: scroll(root);
}
.textimg__text {
mix-blend-mode: difference;
animation: text-drift linear both;
animation-timeline: scroll(root);
}
@keyframes image-drift { to { transform: translateY(-14px); } }
@keyframes text-drift { from { transform: translateY(60px); } to { transform: translateY(-70px); } }
04Hero shrink parallax
What you see
The hero title scales down to about 55% of its starting size and fades to a quarter of its opacity over the same scroll range.

<main class="stage is-demo">
<div class="heroshrink">
<h2 class="heroshrink__title">A hero that shrinks</h2>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$scale-to: .55; // 다 스크롤했을 때 남는 크기
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-blue; // 제목 색 (paper 무대라 blue)
@include stage($stage-paper);
.heroshrink {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.heroshrink__title {
margin: 0;
color: $color;
font: 800 clamp(22px, 8vw, 34px)/1.1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
// ── 실제 사용 코드: 스크롤하는 동안 작아지고 옅어진다 ──
@supports (animation-timeline: scroll()) {
animation: shrink linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) { transform: none; opacity: 1; }
}
@keyframes shrink { from { transform: scale(1); opacity: 1; } to { transform: scale(#{$scale-to}); opacity: .25; } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .heroshrink__title { animation: shrink-loop $duration ease-in-out infinite; }
@keyframes shrink-loop {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale($scale-to); opacity: .25; }
}
@media (prefers-reduced-motion: reduce) {
.heroshrink__title, .stage.is-demo .heroshrink__title { animation: none !important; transform: none; opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.heroshrink {
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.heroshrink__title {
margin: 0;
color: #2f6df6;
font: 800 clamp(22px, 8vw, 34px)/1.1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
text-align: center;
}
@supports (animation-timeline: scroll()) {
.heroshrink__title {
animation: shrink linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.heroshrink__title {
transform: none;
opacity: 1;
}
}
@keyframes shrink {
from {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(0.55);
opacity: 0.25;
}
}
.stage.is-demo .heroshrink__title {
animation: shrink-loop 2s ease-in-out infinite;
}
@keyframes shrink-loop {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(0.55);
opacity: 0.25;
}
}
@media (prefers-reduced-motion: reduce) {
.heroshrink__title, .stage.is-demo .heroshrink__title {
animation: none !important;
transform: none;
opacity: 1;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HeroShrink.module.scss";
export type HeroShrinkProps = {
children: ReactNode;
/** 다 스크롤했을 때 남는 크기 (기본 .55) */
scaleTo?: number;
/** 제목 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--scale-to"?: string; "--color"?: string };
export default function HeroShrink({
children,
scaleTo = 0.55,
color = "#2f6df6",
className = "",
}: HeroShrinkProps) {
const vars: Vars = { "--scale-to": `${scaleTo}`, "--color": color };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<h2 className={styles.title}>{children}</h2>
</div>
);
}04 · Hero shrink parallax — Measured area 3.955% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
Both effects come from one keyframe animation instead of two separate ones. It reads as the headline stepping back to make room, which fits a landing page's first screen or the opening beat of an app-intro sequence.
.heroshrink__title {
animation: shrink linear both;
animation-timeline: scroll(root);
}
@keyframes shrink {
from { transform: scale(1); opacity: 1; }
to { transform: scale(.55); opacity: .25; }
}
05Horizontal timeline parallax
The motion
The card row never receives a horizontal scroll gesture of its own — animation-timeline: scroll(root) reads the page's normal vertical scroll and feeds it straight into a translateX keyframe instead.

<main class="stage is-demo">
<div class="hline">
<span class="hline__label">Timeline</span>
<div class="hline__track">
<span class="hline__card">01</span>
<span class="hline__card">02</span>
<span class="hline__card">03</span>
<span class="hline__card">04</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$travel: 120px; // 가로 이동 거리
$gap: 14px; // 카드 사이 간격
$color: $subject-cream; // 카드 색 (ink 무대라 cream)
@include stage($stage-ink);
.hline {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
align-content: center;
gap: 18px;
}
.hline__label {
padding-left: 16px;
color: rgba($color, .7);
font: 700 clamp(12px, 3.6vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: .04em;
}
.hline__track {
display: flex;
gap: $gap;
padding-left: 16px;
// ── 실제 사용 코드: 세로 스크롤을 그대로 가로 이동으로 쓴다 ──
@supports (animation-timeline: scroll()) {
animation: travel linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) { transform: none; }
}
.hline__card {
flex: none;
width: 64px;
height: 64px;
border-radius: $r-card;
background: $color;
color: $stage-ink;
display: grid;
place-items: center;
font: 700 18px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes travel { from { transform: translateX(0); } to { transform: translateX(-#{$travel}); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .hline__track { animation: travel-loop $dur-loop ease-in-out infinite; }
@keyframes travel-loop { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-$travel); } }
@media (prefers-reduced-motion: reduce) {
.hline__track, .stage.is-demo .hline__track { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hline {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
align-content: center;
gap: 18px;
}
.hline__label {
padding-left: 16px;
color: rgba(255, 247, 230, 0.7);
font: 700 clamp(12px, 3.6vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
letter-spacing: 0.04em;
}
.hline__track {
display: flex;
gap: 14px;
padding-left: 16px;
}
@supports (animation-timeline: scroll()) {
.hline__track {
animation: travel linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.hline__track {
transform: none;
}
}
.hline__card {
flex: none;
width: 64px;
height: 64px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
display: grid;
place-items: center;
font: 700 18px/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes travel {
from {
transform: translateX(0);
}
to {
transform: translateX(-120px);
}
}
.stage.is-demo .hline__track {
animation: travel-loop 2s ease-in-out infinite;
}
@keyframes travel-loop {
0%, 100% {
transform: translateX(0);
}
50% {
transform: translateX(-120px);
}
}
@media (prefers-reduced-motion: reduce) {
.hline__track, .stage.is-demo .hline__track {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./HorizontalTimeline.module.scss";
export type HorizontalTimelineProps = {
/** 카드 내용 (숫자·짧은 라벨) */
items: ReactNode[];
label?: string;
/** 가로 이동 거리 (기본 120px) */
travel?: number;
/** 카드 사이 간격 (기본 14px) */
gap?: number;
className?: string;
};
type Vars = CSSProperties & { "--travel"?: string; "--gap"?: string };
export default function HorizontalTimeline({
items,
label = "Timeline",
travel = 120,
gap = 14,
className = "",
}: HorizontalTimelineProps) {
const vars: Vars = { "--travel": `${travel}px`, "--gap": `${gap}px` };
return (
<div className={`${styles.hline} ${className}`} style={vars}>
<span className={styles.label}>{label}</span>
<div className={styles.track}>
{items.map((item, i) => (
<span key={i} className={styles.card}>
{item}
</span>
))}
</div>
</div>
);
}05 · Horizontal timeline parallax — Measured area 13.686% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
That one substitution turns an ordinary vertical scroll into a row of cards traveling sideways, which fits a company-history page or a product roadmap better than a flat vertical list. It needs no extra scroll container and no scroll-event code to keep the two axes in sync.
.hline__track {
display: flex;
animation: travel linear both;
animation-timeline: scroll(root);
}
@keyframes travel {
from { transform: translateX(0); }
to { transform: translateX(-120px); }
}
06Layered depth cards
What it does
Three cards start stacked with a slight offset and scale difference, and the front card peels away fastest while the two behind it trail at 94% and 88% of its distance, growing slightly as they're revealed.

<main class="stage is-demo">
<div class="depthcards">
<div class="depthcards__card depthcards__card--3" aria-hidden="true"></div>
<div class="depthcards__card depthcards__card--2" aria-hidden="true"></div>
<div class="depthcards__card depthcards__card--1">
<span>Stacked cards</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$peel: 34px; // 맨 앞 카드가 밀려나는 거리
$scale-step: .06; // 뒤 카드일수록 커지는 폭
$color: $subject-ink; // 카드 색 (yellow 무대라 ink)
@include stage($stage-yellow);
.depthcards {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.depthcards__card {
position: absolute;
width: 168px;
height: 108px;
border-radius: $r-card;
box-shadow: $shadow-raised;
display: grid;
place-items: center;
font: 700 clamp(13px, 4vw, 15px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
&--3 {
background: rgba($color, .28);
transform: translateY(18px) scale(.88);
@supports (animation-timeline: scroll()) { animation: peel-3 linear both; animation-timeline: scroll(root); }
}
&--2 {
background: rgba($color, .58);
transform: translateY(9px) scale(.94);
@supports (animation-timeline: scroll()) { animation: peel-2 linear both; animation-timeline: scroll(root); }
}
&--1 {
background: $color;
color: #fff;
@supports (animation-timeline: scroll()) { animation: peel-1 linear both; animation-timeline: scroll(root); }
}
}
// ── 실제 사용 코드: 앞 카드일수록 더 멀리, 더 빨리 밀려난다 ──
@keyframes peel-1 { from { transform: translateY(0) scale(1); } to { transform: translateY(-#{$peel}) scale(1); } }
@keyframes peel-2 { from { transform: translateY(9px) scale(.94); } to { transform: translateY(9px - $peel) scale(.94 + $scale-step); } }
@keyframes peel-3 { from { transform: translateY(18px) scale(.88); } to { transform: translateY(18px - $peel * 1.4) scale(.88 + $scale-step * 2); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .depthcards__card--1 { animation: loop-1 $dur-loop ease-in-out infinite; }
.stage.is-demo .depthcards__card--2 { animation: loop-2 $dur-loop ease-in-out infinite; }
.stage.is-demo .depthcards__card--3 { animation: loop-3 $dur-loop ease-in-out infinite; }
@keyframes loop-1 { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-$peel) scale(1); } }
@keyframes loop-2 { 0%, 100% { transform: translateY(9px) scale(.94); } 50% { transform: translateY(9px - $peel) scale(.94 + $scale-step); } }
@keyframes loop-3 { 0%, 100% { transform: translateY(18px) scale(.88); } 50% { transform: translateY(18px - $peel * 1.4) scale(.88 + $scale-step * 2); } }
@media (prefers-reduced-motion: reduce) {
.depthcards__card--1, .depthcards__card--2, .depthcards__card--3,
.stage.is-demo .depthcards__card--1, .stage.is-demo .depthcards__card--2, .stage.is-demo .depthcards__card--3 {
animation: none !important;
}
.depthcards__card--1 { transform: translateY(0) scale(1); }
.depthcards__card--2 { transform: translateY(9px) scale(.94); }
.depthcards__card--3 { transform: translateY(18px) scale(.88); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.depthcards {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.depthcards__card {
position: absolute;
width: 168px;
height: 108px;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
display: grid;
place-items: center;
font: 700 clamp(13px, 4vw, 15px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.depthcards__card--3 {
background: rgba(23, 20, 26, 0.28);
transform: translateY(18px) scale(0.88);
}
@supports (animation-timeline: scroll()) {
.depthcards__card--3 {
animation: peel-3 linear both;
animation-timeline: scroll(root);
}
}
.depthcards__card--2 {
background: rgba(23, 20, 26, 0.58);
transform: translateY(9px) scale(0.94);
}
@supports (animation-timeline: scroll()) {
.depthcards__card--2 {
animation: peel-2 linear both;
animation-timeline: scroll(root);
}
}
.depthcards__card--1 {
background: #17141a;
color: #fff;
}
@supports (animation-timeline: scroll()) {
.depthcards__card--1 {
animation: peel-1 linear both;
animation-timeline: scroll(root);
}
}
@keyframes peel-1 {
from {
transform: translateY(0) scale(1);
}
to {
transform: translateY(-34px) scale(1);
}
}
@keyframes peel-2 {
from {
transform: translateY(9px) scale(0.94);
}
to {
transform: translateY(-25px) scale(1);
}
}
@keyframes peel-3 {
from {
transform: translateY(18px) scale(0.88);
}
to {
transform: translateY(-29.6px) scale(1);
}
}
.stage.is-demo .depthcards__card--1 {
animation: loop-1 2s ease-in-out infinite;
}
.stage.is-demo .depthcards__card--2 {
animation: loop-2 2s ease-in-out infinite;
}
.stage.is-demo .depthcards__card--3 {
animation: loop-3 2s ease-in-out infinite;
}
@keyframes loop-1 {
0%, 100% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-34px) scale(1);
}
}
@keyframes loop-2 {
0%, 100% {
transform: translateY(9px) scale(0.94);
}
50% {
transform: translateY(-25px) scale(1);
}
}
@keyframes loop-3 {
0%, 100% {
transform: translateY(18px) scale(0.88);
}
50% {
transform: translateY(-29.6px) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.depthcards__card--1, .depthcards__card--2, .depthcards__card--3,
.stage.is-demo .depthcards__card--1, .stage.is-demo .depthcards__card--2, .stage.is-demo .depthcards__card--3 {
animation: none !important;
}
.depthcards__card--1 {
transform: translateY(0) scale(1);
}
.depthcards__card--2 {
transform: translateY(9px) scale(0.94);
}
.depthcards__card--3 {
transform: translateY(18px) scale(0.88);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./DepthCards.module.scss";
export type DepthCardsProps = {
/** 맨 앞 카드 라벨 */
children: ReactNode;
/** 맨 앞 카드가 밀려나는 거리 (기본 34px) */
peel?: number;
/** 뒤 카드일수록 커지는 폭 (기본 .06) */
scaleStep?: number;
className?: string;
};
type Vars = CSSProperties & { "--peel"?: string; "--scale-step"?: string };
export default function DepthCards({
children,
peel = 34,
scaleStep = 0.06,
className = "",
}: DepthCardsProps) {
const vars: Vars = { "--peel": `${peel}px`, "--scale-step": `${scaleStep}` };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={`${styles.card} ${styles.card3}`} aria-hidden="true" />
<div className={`${styles.card} ${styles.card2}`} aria-hidden="true" />
<div className={`${styles.card} ${styles.card1}`}>{children}</div>
</div>
);
}06 · Layered depth cards — Measured area 16.794% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
The scale change is what sells the stack — without it the three cards would just look like they slide at different speeds instead of sitting at different depths. Feature-highlight cards and pricing comparisons both read well with this, since revealing one card at a time keeps attention on a single option.
.depthcards__card--1 {
animation: peel-1 linear both;
animation-timeline: scroll(root);
}
.depthcards__card--2 { transform: translateY(9px) scale(.94); }
.depthcards__card--3 { transform: translateY(18px) scale(.88); }
@keyframes peel-1 { to { transform: translateY(-34px) scale(1); } }
07Sticky + parallax combo
What you see
position: sticky pins the caption at 40% from the top of its container while the striped background behind it keeps drifting on its own animation-timeline: scroll(root) track.

<main class="stage is-demo">
<div class="stickypar">
<div class="stickypar__bg" aria-hidden="true"></div>
<p class="stickypar__caption">A caption that stays put</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$bg-shift: 40px; // 뒤 배경 이동량
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-cream; // 배경 결·문장 글자 색 (orange 무대라 cream)
@include stage($stage-orange);
.stickypar {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
}
.stickypar__bg {
position: absolute;
inset: -20% 0;
background: repeating-linear-gradient(45deg, rgba($color, .22) 0 18px, transparent 18px 36px);
// ── 실제 사용 코드: 앞 문장은 sticky 로 붙박이, 뒤 배경만 흘러간다 ──
@supports (animation-timeline: scroll()) {
animation: bg-drift linear both;
animation-timeline: scroll(root);
}
@supports not (animation-timeline: scroll()) { transform: none; }
}
.stickypar__caption {
position: sticky;
top: 40%;
z-index: 1;
margin: 0;
padding: 12px 22px;
border-radius: $r-pill;
background: $stage-ink;
color: $color;
font: 700 clamp(14px, 4.4vw, 17px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes bg-drift { from { transform: translateY(0); } to { transform: translateY(-#{$bg-shift}); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .stickypar__bg { animation: bg-loop $duration ease-in-out infinite; }
@keyframes bg-loop { 0%, 100% { transform: translateY($bg-shift); } 50% { transform: translateY(-$bg-shift); } }
@media (prefers-reduced-motion: reduce) {
.stickypar__bg, .stage.is-demo .stickypar__bg { animation: none !important; transform: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stickypar {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
}
.stickypar__bg {
position: absolute;
inset: -20% 0;
background: repeating-linear-gradient(45deg, rgba(255, 247, 230, 0.22) 0 18px, transparent 18px 36px);
}
@supports (animation-timeline: scroll()) {
.stickypar__bg {
animation: bg-drift linear both;
animation-timeline: scroll(root);
}
}
@supports not (animation-timeline: scroll()) {
.stickypar__bg {
transform: none;
}
}
.stickypar__caption {
position: sticky;
top: 40%;
z-index: 1;
margin: 0;
padding: 12px 22px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font: 700 clamp(14px, 4.4vw, 17px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes bg-drift {
from {
transform: translateY(0);
}
to {
transform: translateY(-40px);
}
}
.stage.is-demo .stickypar__bg {
animation: bg-loop 2s ease-in-out infinite;
}
@keyframes bg-loop {
0%, 100% {
transform: translateY(40px);
}
50% {
transform: translateY(-40px);
}
}
@media (prefers-reduced-motion: reduce) {
.stickypar__bg, .stage.is-demo .stickypar__bg {
animation: none !important;
transform: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./StickyParallax.module.scss";
export type StickyParallaxProps = {
children: ReactNode;
/** 뒤 배경 이동량 (기본 40px) */
bgShift?: number;
/** 배경 결·문장 글자 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--bg-shift"?: string; "--color"?: string };
export default function StickyParallax({
children,
bgShift = 40,
color = "#fff7e6",
className = "",
}: StickyParallaxProps) {
const vars: Vars = { "--bg-shift": `${bgShift}px`, "--color": color };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.bg} aria-hidden="true" />
<p className={styles.caption}>{children}</p>
</div>
);
}07 · Sticky + parallax combo — Measured area 77.093% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
The two layers visibly separate as a result, instead of scrolling together. Storytelling sections and product-spec walkthroughs both lean on this, since a caption that holds still gives a reader time to finish the sentence before the next background pattern arrives.
.stickypar__caption {
position: sticky;
top: 40%;
}
.stickypar__bg {
animation: bg-drift linear both;
animation-timeline: scroll(root);
}
@keyframes bg-drift {
from { transform: translateY(0); }
to { transform: translateY(-40px); }
}
08Mouse-move parallax
The motion
Three circles trail the cursor at three different multipliers — 0.3, 0.6, and 1 — so the layer with the highest multiplier swings furthest and reads as the closest one to the viewer.

<main class="stage is-demo">
<div class="mousepar">
<div class="mousepar__layer mousepar__layer--a" style="--depth:.3" aria-hidden="true"></div>
<div class="mousepar__layer mousepar__layer--b" style="--depth:.6" aria-hidden="true"></div>
<div class="mousepar__layer mousepar__layer--c" style="--depth:1" aria-hidden="true"></div>
<p class="mousepar__label">Follows your cursor</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$amp: 22px; // 커서 정규화값 1당 이동량
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-blue; // 레이어 색 (paper 무대라 blue)
@include stage($stage-paper);
.mousepar {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.mousepar__layer {
position: absolute;
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba($color, .5);
transition: transform 120ms $ease-out;
// ── 실제 사용 코드: pointermove 로 받은 --mx·--my 에 레이어별 --depth 를 곱해 이동한다 ──
&--a { left: 18%; top: 24%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
&--b { left: 62%; top: 54%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
&--c { left: 40%; top: 68%; transform: translate(calc(var(--mx, 0) * var(--depth) * #{$amp}), calc(var(--my, 0) * var(--depth) * #{$amp})); }
}
.mousepar__label {
position: relative;
z-index: 1;
margin: 0;
padding: 12px 20px;
border-radius: $r-pill;
background: $stage-ink;
color: #fff;
font: 700 clamp(13px, 4.2vw, 16px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 데모(.is-demo): 커서 대신 2초 루프로 흉내 ────────
.stage.is-demo .mousepar__layer--a { animation: loop-a $duration ease-in-out infinite; }
.stage.is-demo .mousepar__layer--b { animation: loop-b $duration ease-in-out infinite; }
.stage.is-demo .mousepar__layer--c { animation: loop-c $duration ease-in-out infinite; }
@keyframes loop-a { 0%, 100% { transform: translate(-10px, -6px); } 50% { transform: translate(10px, 6px); } }
@keyframes loop-b { 0%, 100% { transform: translate(16px, 10px); } 50% { transform: translate(-16px, -10px); } }
@keyframes loop-c { 0%, 100% { transform: translate(-24px, -14px); } 50% { transform: translate(24px, 14px); } }
@media (prefers-reduced-motion: reduce) {
.mousepar__layer,
.stage.is-demo .mousepar__layer--a, .stage.is-demo .mousepar__layer--b, .stage.is-demo .mousepar__layer--c {
animation: none !important;
transform: none !important;
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mousepar {
position: relative;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.mousepar__layer {
position: absolute;
width: 46px;
height: 46px;
border-radius: 50%;
background: rgba(47, 109, 246, 0.5);
transition: transform 120ms ease-out;
}
.mousepar__layer--a {
left: 18%;
top: 24%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__layer--b {
left: 62%;
top: 54%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__layer--c {
left: 40%;
top: 68%;
transform: translate(calc(var(--mx, 0) * var(--depth) * 22px), calc(var(--my, 0) * var(--depth) * 22px));
}
.mousepar__label {
position: relative;
z-index: 1;
margin: 0;
padding: 12px 20px;
border-radius: 999px;
background: #17141a;
color: #fff;
font: 700 clamp(13px, 4.2vw, 16px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.stage.is-demo .mousepar__layer--a {
animation: loop-a 2s ease-in-out infinite;
}
.stage.is-demo .mousepar__layer--b {
animation: loop-b 2s ease-in-out infinite;
}
.stage.is-demo .mousepar__layer--c {
animation: loop-c 2s ease-in-out infinite;
}
@keyframes loop-a {
0%, 100% {
transform: translate(-10px, -6px);
}
50% {
transform: translate(10px, 6px);
}
}
@keyframes loop-b {
0%, 100% {
transform: translate(16px, 10px);
}
50% {
transform: translate(-16px, -10px);
}
}
@keyframes loop-c {
0%, 100% {
transform: translate(-24px, -14px);
}
50% {
transform: translate(24px, 14px);
}
}
@media (prefers-reduced-motion: reduce) {
.mousepar__layer,
.stage.is-demo .mousepar__layer--a, .stage.is-demo .mousepar__layer--b, .stage.is-demo .mousepar__layer--c {
animation: none !important;
transform: none !important;
transition: none;
}
}// 실제 사용: 커서 위치를 -0.5~0.5 로 정규화해 CSS 변수로 넘긴다. 레이어는 --depth 로 배율만 다르게 받는다.
var el = document.querySelector('.mousepar');
el.addEventListener('pointermove', function (e) {
var r = el.getBoundingClientRect();
el.style.setProperty('--mx', ((e.clientX - r.left) / r.width - 0.5).toFixed(3));
el.style.setProperty('--my', ((e.clientY - r.top) / r.height - 0.5).toFixed(3));
});import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./MouseParallax.module.scss";
export type MouseParallaxProps = {
children: ReactNode;
/** 커서 정규화값 1당 이동량 (기본 22px) */
amp?: number;
/** 레이어 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--amp"?: string; "--color"?: string; "--mx"?: string; "--my"?: string };
export default function MouseParallax({ children, amp = 22, color = "#2f6df6", className = "" }: MouseParallaxProps) {
const ref = useRef<HTMLDivElement>(null);
const handle = useCallback((e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
ref.current?.style.setProperty("--mx", `${(e.clientX - r.left) / r.width - 0.5}`);
ref.current?.style.setProperty("--my", `${(e.clientY - r.top) / r.height - 0.5}`);
}, []);
const vars: Vars = { "--amp": `${amp}px`, "--color": color };
return (
<div ref={ref} className={`${styles.wrap} ${className}`} style={vars} onPointerMove={handle}>
<div className={styles.layer} style={{ "--depth": ".3", left: "18%", top: "24%" } as Vars} aria-hidden="true" />
<div className={styles.layer} style={{ "--depth": ".6", left: "62%", top: "54%" } as Vars} aria-hidden="true" />
<div className={styles.layer} style={{ "--depth": "1", left: "40%", top: "68%" } as Vars} aria-hidden="true" />
<p className={styles.label}>{children}</p>
</div>
);
}08 · Mouse-move parallax — Measured area 6.447% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
Getting the cursor into CSS still takes a few lines of script: a pointermove listener normalizes the cursor to a -0.5 to 0.5 range and writes it into two custom properties the layers read. It fits a product showcase or a portfolio hero where a viewer is expected to rest their cursor over the piece rather than scroll straight past it.
// --mx and --my come from a pointermove listener, -0.5 to 0.5
.mousepar__layer {
transform: translate(
calc(var(--mx, 0) * var(--depth) * 22px),
calc(var(--my, 0) * var(--depth) * 22px)
);
transition: transform 120ms ease-out;
}
.mousepar__layer--a { left: 18%; top: 24%; }
.mousepar__layer--b { left: 62%; top: 54%; }
.mousepar__layer--c { left: 40%; top: 68%; }
09Scroll zoom-out
What it does
The media block starts scaled up to 145% of its real size, and animation-timeline: scroll(root) drives it back down to 100% as the page scrolls.

<main class="stage is-demo">
<div class="zoomout">
<div class="zoomout__media" aria-hidden="true"></div>
<span class="zoomout__label">Shrinks as you scroll</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$scale-from: 1.45; // 처음 확대 배율
$duration: $dur-loop; // 데모 왕복 주기
$color: $subject-cream; // 라벨 글자 색 (ink 무대라 cream)
@include stage($stage-ink);
.zoomout {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
}
.zoomout__media {
position: absolute;
inset: 6%;
border-radius: $r-card;
background: linear-gradient(135deg, rgba($color, .5), rgba($subject-blue, .5));
transform: scale(#{$scale-from});
// ── 실제 사용 코드: 확대돼 있던 화면이 스크롤할수록 줄어든다 ──
@supports (animation-timeline: scroll()) {
animation: zoom-out linear both;
animation-timeline: scroll(root);
}
}
.zoomout__label {
position: relative;
z-index: 1;
padding: 10px 18px;
border-radius: $r-pill;
background: $stage-ink;
border: 1px solid rgba($color, .5);
color: $color;
font: 700 clamp(12px, 3.8vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes zoom-out { from { transform: scale(#{$scale-from}); } to { transform: scale(1); } }
// ── 데모(.is-demo): 왕복 루프로 흉내 ────────
.stage.is-demo .zoomout__media { animation: zoom-loop $duration ease-in-out infinite; }
@keyframes zoom-loop { 0%, 100% { transform: scale(#{$scale-from}); } 50% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) {
.zoomout__media, .stage.is-demo .zoomout__media { animation: none !important; transform: scale(1); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.zoomout {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: grid;
place-items: center;
}
.zoomout__media {
position: absolute;
inset: 6%;
border-radius: 16px;
background: linear-gradient(135deg, rgba(255, 247, 230, 0.5), rgba(47, 109, 246, 0.5));
transform: scale(1.45);
}
@supports (animation-timeline: scroll()) {
.zoomout__media {
animation: zoom-out linear both;
animation-timeline: scroll(root);
}
}
.zoomout__label {
position: relative;
z-index: 1;
padding: 10px 18px;
border-radius: 999px;
background: #17141a;
border: 1px solid rgba(255, 247, 230, 0.5);
color: #fff7e6;
font: 700 clamp(12px, 3.8vw, 14px)/1 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes zoom-out {
from {
transform: scale(1.45);
}
to {
transform: scale(1);
}
}
.stage.is-demo .zoomout__media {
animation: zoom-loop 2s ease-in-out infinite;
}
@keyframes zoom-loop {
0%, 100% {
transform: scale(1.45);
}
50% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.zoomout__media, .stage.is-demo .zoomout__media {
animation: none !important;
transform: scale(1);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./ZoomOutScroll.module.scss";
export type ZoomOutScrollProps = {
children: ReactNode;
/** 처음 확대 배율 (기본 1.45) */
scaleFrom?: number;
/** 라벨 글자 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--scale-from"?: string; "--color"?: string };
export default function ZoomOutScroll({
children,
scaleFrom = 1.45,
color = "#fff7e6",
className = "",
}: ZoomOutScrollProps) {
const vars: Vars = { "--scale-from": `${scaleFrom}`, "--color": color };
return (
<div className={`${styles.wrap} ${className}`} style={vars}>
<div className={styles.media} aria-hidden="true" />
<span className={styles.label}>{children}</span>
</div>
);
}09 · Scroll zoom-out — Measured area 22.201% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
As a result, the frame around it only becomes visible once the zoom finishes. It suits a product-detail opener or a gallery intro where the first thing a visitor sees should feel like a close-up before the fuller page reveals itself.
.zoomout__media {
transform: scale(1.45);
animation: zoom-out linear both;
animation-timeline: scroll(root);
}
@keyframes zoom-out {
from { transform: scale(1.45); }
to { transform: scale(1); }
}
Where it breaks — the traps
Support for scroll timelines
animation-timeline: scroll(root) is the one property every pattern above depends on, and it isn't universal yet. caniuse tracks it as supported from Chrome 115 and Safari 26, with Firefox still shipping it behind a flag as of September 2026. Every demo above wraps its scroll(root) animation in an @supports (animation-timeline: scroll()) block, so a browser that doesn't understand the property falls back to a still layer instead of a half-broken one. Skip that guard and a Firefox reader on the stable channel sees item 04's hero permanently mid-shrink, because the base transform still applies but the keyframe animation driving it never starts. Grab the nine as working files rather than retyping every guard by hand: the archive opens once you type x6462unj exactly as it reads here, with no dashes or spaces added. Every pattern inside carries the same fallback, not just the one shown above. The same amplitude caution applies when the trigger isn't scroll at all — 08's cursor-following layers obey it too, and the CSS shake animations solve the identical problem for form errors by keeping the swing small.
Accessibility
Reduced motion
Every pattern above only stops moving under prefers-reduced-motion because its own reduced-motion block cancels it, and each one throws away a different amount of motion rather than freezing identically. Items 01, 02, 05, and 07 drop their animation entirely and hold a still transform, so each background stays exactly where it starts instead of drifting. Item 09's media snaps straight to its fully revealed, un-zoomed state instead of looping back and forth. Item 04's title keeps its full opacity and size rather than settling mid-shrink, and item 08 cancels both its animation and its pointer-driven transition so nothing trails the cursor at all. MDN documents prefers-reduced-motion and the operating-system settings that turn it on. Items 03 and 06 simply rest in their final layout: the caption sits still over the image, and the three cards stay stacked without peeling apart.
More css parallax scrolling examples and every other scroll-driven pattern this site has published sit behind the CSS hub, and the about page covers what these files include and what they leave out.
FAQ
Does CSS parallax scrolling work without JavaScript?
Eight of the nine patterns above run purely on animation-timeline: scroll(root), so scrolling itself drives every keyframe with zero JavaScript attached to the page. The one exception is the mouse-move layer in item 08, which needs a short pointermove listener because a cursor position has no scroll-linked timeline of its own to read from. Everything else here — the shrinking hero, the sticky caption, the horizontal timeline — ships as pure CSS. Assembling these onto one whole page rather than one section is the picture the website scroll effects collect.
Why does my parallax layer jump instead of moving smoothly with the scroll?
That's almost always animation-timeline: scroll(root) failing silently rather than throwing an error. Check that the @supports (animation-timeline: scroll()) guard actually wraps the animation, and confirm the browser is one of the versions that supports the property. A layer set to position: fixed instead of absolute or sticky inside its scrolling container is the second most common cause, since a fixed element ignores the very scroll it's meant to track.
Which css parallax scroll animation is fastest to add to an existing page?
Item 01's slow-moving background is the smallest change — one element, one keyframe, and one animation-timeline line, with no layout restructuring needed around it. Item 08's mouse-move parallax takes the longest to wire up, since it needs a pointermove listener, a couple of custom properties, and per-layer depth values tuned by hand.









