9 CSS Background Animation — One Line, Copy-Paste
A css background animation is a decorative layer — a shifting gradient, a drifting grid, blurred shapes — that loops behind real content using only
01 background-animation-gradient-shift
02 background-animation-mesh-gradient
03 background-animation-aurora-blur
04 background-animation-grid-lines-drift
05 background-animation-blob-morph
06 background-animation-noise-grain
07 background-animation-dot-pattern-drift
08 background-animation-stripes-diagonal
09 background-animation-spotlight-cursor-bg
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Gradient shift
- 02 Mesh gradient drift
- 03 Aurora blur
- 04 Drifting grid lines
- 05 Blob morph
- 06 Noise grain overlay
- 07 Drifting dot pattern
- 08 Diagonal stripe move
- 09 Cursor spotlight background
The nine below move from the simplest trick to the most involved one. 01 shifts nothing but background-position under an oversized gradient. 02, 03, and 05 stack several blurred or morphing shapes and move each one on its own path with transform. 04, 07, and 08 tile a repeating pattern and slide it three different ways, so the eye keeps reading motion instead of a fixed image. 06 fakes grain by jumping background-position with steps() instead of easing it. 09 is the odd one out — it only orbits on its own here because the real version follows the cursor through pointermove. The grid above never waits for a click: watch it for a few seconds and every one of the nine has already looped once, sealed inside a stage that doesn't scroll. When the motion belongs to the words instead of the ground they sit on, the CSS text animation effects are that half, and for photos laid over the backdrop that have to answer the pointer, the CSS image hover effects pair up with it.
01Gradient shift
Where the css background animation loop starts
A gradient is painted at 220% of its own box in both directions, and only background-position slides between two edges of that oversized canvas. The color band appears to flow like water instead of jumping between fixed stops.
Where it fits
Reach for it on a hero section or a premium call-to-action button, anywhere a background needs to feel alive without asking the browser to redraw the gradient itself.

<main class="stage">
<p class="hint">Hero background</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개. 변형은 이것만 바꾼다 ──────────────────────────
$duration: $dur-loop; // 그라데이션이 한 바퀴 흐르는 시간
$easing: $ease-out;
$color: $subject-blue; // 흐르는 그라데이션의 포인트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.stage {
position: relative;
overflow: hidden;
}
// 실제 배경은 스테이지보다 두 배 크게 깔고 background-position 만 움직인다 (transform·opacity 만 쓰는 규칙의 배경판)
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(120deg, $stage-ink, $color, $subject-cream, $stage-ink);
background-size: 220% 220%;
animation: flow $duration $easing infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .35);
color: $stage-paper;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
// ── 접근성: 움직임 줄이기 설정이면 정지 프레임만 ────────────
@media (prefers-reduced-motion: reduce) {
.stage::before { animation: none; background-position: 0% 50%; }
}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);
}
.stage {
position: relative;
overflow: hidden;
}
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(120deg, #17141a, #2f6df6, #fff7e6, #17141a);
background-size: 220% 220%;
animation: flow 2s ease-out infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.35);
color: #ffffff;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.stage::before {
animation: none;
background-position: 0% 50%;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./GradientShiftBg.module.scss";
export type GradientShiftBgProps = {
children?: ReactNode;
/** 한 바퀴 흐르는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 흐르는 그라데이션의 포인트 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function GradientShiftBg({
children,
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
className = "",
}: GradientShiftBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<div className={styles.bg} aria-hidden="true" />
{children}
</div>
);
}01 · Gradient shift — Measured area 99.739% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(120deg, $stage-ink, $color, $subject-cream, $stage-ink);
background-size: 220% 220%;
animation: flow $duration $easing infinite;
}
@keyframes flow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
02Mesh gradient drift
How it works
Three radial-gradient blobs in different colors sit on top of each other, each one blurred and moved along its own translate path with its own animation-delay. The timing never lines up, so the blobs keep re-blending into new mixes instead of pulsing in unison.
Where it fits
It reads as a SaaS landing hero or an app onboarding backdrop, the kind of soft moving color field that looks hand-tuned rather than templated.

<main class="stage">
<span class="blob b1" aria-hidden="true"></span>
<span class="blob b2" aria-hidden="true"></span>
<span class="blob b3" aria-hidden="true"></span>
<p class="hint">SaaS landing</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 얼룩 하나가 경로를 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-blue; // 얼룩 색 하나
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.stage {
position: relative;
overflow: hidden;
}
// 서로 다른 색의 radial-gradient 얼룩 세 개를 겹치고, transform 으로만 각자 다른 경로를 돈다
.blob {
position: absolute;
width: 42%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(12px);
opacity: .9;
}
.b1 { background: radial-gradient($color, transparent 65%); top: -8%; left: -6%; animation: drift1 $duration $easing infinite; }
.b2 { background: radial-gradient($subject-cream, transparent 65%); bottom: -12%; right: -4%; animation: drift2 $duration $easing infinite; animation-delay: -.6s; }
.b3 { background: radial-gradient($stage-ink, transparent 65%); top: 28%; right: 22%; opacity: .65; animation: drift3 $duration $easing infinite; animation-delay: -1.2s; }
@keyframes drift1 { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(48px, 32px); } }
@keyframes drift2 { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(-42px, -26px); } }
@keyframes drift3 { 0%, 100% { transform: translate(0, 0); } 50% { transform: translate(24px, -38px); } }
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .15);
color: $stage-ink;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.blob { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.blob {
position: absolute;
width: 42%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(12px);
opacity: 0.9;
}
.b1 {
background: radial-gradient(#2f6df6, transparent 65%);
top: -8%;
left: -6%;
animation: drift1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.b2 {
background: radial-gradient(#fff7e6, transparent 65%);
bottom: -12%;
right: -4%;
animation: drift2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -0.6s;
}
.b3 {
background: radial-gradient(#17141a, transparent 65%);
top: 28%;
right: 22%;
opacity: 0.65;
animation: drift3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: -1.2s;
}
@keyframes drift1 {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(48px, 32px);
}
}
@keyframes drift2 {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(-42px, -26px);
}
}
@keyframes drift3 {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(24px, -38px);
}
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.15);
color: #17141a;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.blob {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./MeshGradientBg.module.scss";
export type MeshGradientBgProps = {
children?: ReactNode;
/** 얼룩 하나가 경로를 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 얼룩 색 하나 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MeshGradientBg({
children,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
className = "",
}: MeshGradientBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<span className={`${styles.blob} ${styles.b1}`} aria-hidden="true" />
<span className={`${styles.blob} ${styles.b2}`} aria-hidden="true" />
<span className={`${styles.blob} ${styles.b3}`} aria-hidden="true" />
{children}
</div>
);
}02 · Mesh gradient drift — Measured area 52.819% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
.blob {
position: absolute;
width: 42%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(12px);
opacity: .9;
}
.b1 { background: radial-gradient($color, transparent 65%); top: -8%; left: -6%; animation: drift1 $duration $easing infinite; }
.b2 { background: radial-gradient($subject-cream, transparent 65%); bottom: -12%; right: -4%; animation: drift2 $duration $easing infinite; animation-delay: -.6s; }
.b3 { background: radial-gradient($stage-ink, transparent 65%); top: 28%; right: 22%; opacity: .65; animation: drift3 $duration $easing infinite; animation-delay: -1.2s; }
03Aurora blur
How it works
Two or three blurred, semi-transparent color shapes overlap at filter: blur(3px) and move only through transform — translate and rotate together, never blur or opacity. Each shape swings through a loop that looks like layered light rather than a spinning sticker.
Where it fits
Staggered animation-delay values keep the three from ever rotating in sync, which is what sells a dark hero background or the ambient lighting behind a premium landing section.

<main class="stage">
<span class="aurora a1" aria-hidden="true"></span>
<span class="aurora a2" aria-hidden="true"></span>
<span class="aurora a3" aria-hidden="true"></span>
<p class="hint">Dark hero</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 덩어리 하나가 경로를 한 바퀴 도는 시간
$easing: $ease-out;
$color: $subject-blue; // 덩어리 색 하나
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.stage {
position: relative;
overflow: hidden;
}
// filter: blur 로 흐린 덩어리를 겹치고, transform(translate·rotate)으로만 움직인다
.aurora {
position: absolute;
width: 38%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(3px);
opacity: 1;
}
.a1 { background: radial-gradient($subject-cream 0%, $subject-cream 25%, transparent 55%); top: -8%; left: 8%; animation: spin1 $duration linear infinite; }
.a2 { background: radial-gradient($stage-ink 0%, $stage-ink 25%, transparent 55%); bottom: -12%; right: 4%; animation: spin2 $duration linear infinite; animation-delay: -1s; }
.a3 { background: radial-gradient($color 0%, $color 25%, transparent 55%); top: 22%; right: 28%; animation: spin3 $duration linear infinite; animation-delay: -.5s; }
@keyframes spin1 { 0% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(56px, 38px) rotate(180deg); } 100% { transform: translate(0, 0) rotate(360deg); } }
@keyframes spin2 { 0% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(-50px, -30px) rotate(-160deg); } 100% { transform: translate(0, 0) rotate(-360deg); } }
@keyframes spin3 { 0% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(36px, -48px) rotate(150deg); } 100% { transform: translate(0, 0) rotate(300deg); } }
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .3);
color: $stage-paper;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.aurora { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.aurora {
position: absolute;
width: 38%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(3px);
opacity: 1;
}
.a1 {
background: radial-gradient(#fff7e6 0%, #fff7e6 25%, transparent 55%);
top: -8%;
left: 8%;
animation: spin1 2s linear infinite;
}
.a2 {
background: radial-gradient(#17141a 0%, #17141a 25%, transparent 55%);
bottom: -12%;
right: 4%;
animation: spin2 2s linear infinite;
animation-delay: -1s;
}
.a3 {
background: radial-gradient(#2f6df6 0%, #2f6df6 25%, transparent 55%);
top: 22%;
right: 28%;
animation: spin3 2s linear infinite;
animation-delay: -0.5s;
}
@keyframes spin1 {
0% {
transform: translate(0, 0) rotate(0deg);
}
50% {
transform: translate(56px, 38px) rotate(180deg);
}
100% {
transform: translate(0, 0) rotate(360deg);
}
}
@keyframes spin2 {
0% {
transform: translate(0, 0) rotate(0deg);
}
50% {
transform: translate(-50px, -30px) rotate(-160deg);
}
100% {
transform: translate(0, 0) rotate(-360deg);
}
}
@keyframes spin3 {
0% {
transform: translate(0, 0) rotate(0deg);
}
50% {
transform: translate(36px, -48px) rotate(150deg);
}
100% {
transform: translate(0, 0) rotate(300deg);
}
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.3);
color: #ffffff;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.aurora {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./AuroraBlurBg.module.scss";
export type AuroraBlurBgProps = {
children?: ReactNode;
/** 덩어리 하나가 경로를 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 덩어리 색 하나 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function AuroraBlurBg({
children,
duration = 2000,
easing = "linear",
color = "#2f6df6",
className = "",
}: AuroraBlurBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
void easing; // linear 고정 — spin 키프레임은 등속이 자연스럽다
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<span className={`${styles.aurora} ${styles.a1}`} aria-hidden="true" />
<span className={`${styles.aurora} ${styles.a2}`} aria-hidden="true" />
<span className={`${styles.aurora} ${styles.a3}`} aria-hidden="true" />
{children}
</div>
);
}03 · Aurora blur — Measured area 41.671% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.aurora {
position: absolute;
width: 38%;
aspect-ratio: 1;
border-radius: 50%;
filter: blur(3px);
opacity: 1;
}
.a1 { background: radial-gradient($subject-cream 0%, $subject-cream 25%, transparent 55%); top: -8%; left: 8%; animation: spin1 $duration linear infinite; }
@keyframes spin1 { 0% { transform: translate(0, 0) rotate(0deg); } 50% { transform: translate(56px, 38px) rotate(180deg); } 100% { transform: translate(0, 0) rotate(360deg); } }
04Drifting grid lines
How it works
Two repeating-linear-gradient layers, one horizontal and one vertical, draw a plain grid, and a single linear-eased keyframe slides both layers' background-position across exactly one 32px cell. The loop has no visible seam where it restarts.
Where it fits
It fits a developer-tool hero or the empty state behind a data dashboard, anywhere a grid should read as infrastructure rather than decoration.

<main class="stage">
<p class="hint">Dashboard background</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 격자 한 칸만큼 흐르는 시간
$easing: linear; // 등속으로 흘러야 격자가 자연스럽다
$color: $subject-blue; // 격자선 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.stage {
position: relative;
overflow: hidden;
}
// repeating-linear-gradient 두 겹(가로·세로)을 한 배경에 겹쳐 격자를 그리고 background-position 만 대각선으로 흘려보낸다
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba($color, .35) 1px, transparent 1px),
linear-gradient(to bottom, rgba($color, .35) 1px, transparent 1px);
background-size: 32px 32px, 32px 32px;
animation: gridmove $duration $easing infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .08);
color: $stage-ink;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes gridmove {
from { background-position: 0 0, 0 0; }
to { background-position: 32px 32px, 32px 32px; }
}
@media (prefers-reduced-motion: reduce) {
.stage::before { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(to right, rgba(47, 109, 246, 0.35) 1px, transparent 1px), linear-gradient(to bottom, rgba(47, 109, 246, 0.35) 1px, transparent 1px);
background-size: 32px 32px, 32px 32px;
animation: gridmove 2s linear infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes gridmove {
from {
background-position: 0 0, 0 0;
}
to {
background-position: 32px 32px, 32px 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.stage::before {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./GridLinesDriftBg.module.scss";
export type GridLinesDriftBgProps = {
children?: ReactNode;
/** 격자 한 칸만큼 흐르는 시간 (기본 2000ms) */
duration?: number;
/** 격자선 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function GridLinesDriftBg({
children,
duration = 2000,
color = "#2f6df6",
className = "",
}: GridLinesDriftBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<div className={styles.grid} aria-hidden="true" />
{children}
</div>
);
}04 · Drifting grid lines — Measured area 99.682% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(to right, rgba($color, .35) 1px, transparent 1px),
linear-gradient(to bottom, rgba($color, .35) 1px, transparent 1px);
background-size: 32px 32px, 32px 32px;
animation: gridmove $duration $easing infinite;
}
@keyframes gridmove {
from { background-position: 0 0, 0 0; }
to { background-position: 32px 32px, 32px 32px; }
}
05Blob morph
How it works
Three blobs each keep one fixed, hand-picked border-radius value for their whole lifetime, and only opacity and transform (scale, rotate) cross-fade between them on a staggered schedule. The mass looks like it keeps reshaping itself without the browser ever recalculating a border-radius curve mid-frame.
Where it fits
It suits an about-page decoration or the illustration behind an empty state, somewhere a blob can breathe without competing with real content.

<main class="stage">
<span class="blob m1" aria-hidden="true"></span>
<span class="blob m2" aria-hidden="true"></span>
<span class="blob m3" aria-hidden="true"></span>
<p class="hint">Empty state</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 세 블롭이 한 바퀴 교차하는 시간
$easing: $ease-spring;
$color: $subject-cream; // 블롭 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.stage {
position: relative;
overflow: hidden;
}
// border-radius 는 세 블롭마다 고정값. opacity·transform 만 움직여 border-radius 를 매 프레임 다시 계산하지 않는다
.blob {
position: absolute;
width: 52%;
aspect-ratio: 1;
left: 24%;
top: 18%;
background: $color;
}
.m1 { border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%; animation: morph1 $duration $easing infinite; }
.m2 { border-radius: 63% 37% 41% 59% / 55% 48% 52% 45%; opacity: 0; animation: morph2 $duration $easing infinite; }
.m3 { border-radius: 35% 65% 55% 45% / 60% 40% 60% 40%; opacity: 0; animation: morph3 $duration $easing infinite; }
@keyframes morph1 {
0% { opacity: 1; transform: scale(1) rotate(0deg); }
28% { opacity: 1; transform: scale(1.05) rotate(4deg); }
38% { opacity: 0; transform: scale(.94) rotate(8deg); }
92% { opacity: 0; }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes morph2 {
0%, 30% { opacity: 0; }
40% { opacity: 1; transform: scale(1) rotate(0deg); }
68% { opacity: 1; transform: scale(1.05) rotate(-4deg); }
78% { opacity: 0; transform: scale(.94) rotate(-8deg); }
100% { opacity: 0; }
}
@keyframes morph3 {
0%, 70% { opacity: 0; }
80% { opacity: 1; transform: scale(1) rotate(0deg); }
96% { opacity: 1; transform: scale(1.05) rotate(4deg); }
100% { opacity: 0; }
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.blob { animation: none; }
.m1 { opacity: 1; }
.m2, .m3 { opacity: 0; }
}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);
}
.stage {
position: relative;
overflow: hidden;
}
.blob {
position: absolute;
width: 52%;
aspect-ratio: 1;
left: 24%;
top: 18%;
background: #fff7e6;
}
.m1 {
border-radius: 42% 58% 63% 37%/41% 44% 56% 59%;
animation: morph1 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.m2 {
border-radius: 63% 37% 41% 59%/55% 48% 52% 45%;
opacity: 0;
animation: morph2 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.m3 {
border-radius: 35% 65% 55% 45%/60% 40% 60% 40%;
opacity: 0;
animation: morph3 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes morph1 {
0% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
28% {
opacity: 1;
transform: scale(1.05) rotate(4deg);
}
38% {
opacity: 0;
transform: scale(0.94) rotate(8deg);
}
92% {
opacity: 0;
}
100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
}
@keyframes morph2 {
0%, 30% {
opacity: 0;
}
40% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
68% {
opacity: 1;
transform: scale(1.05) rotate(-4deg);
}
78% {
opacity: 0;
transform: scale(0.94) rotate(-8deg);
}
100% {
opacity: 0;
}
}
@keyframes morph3 {
0%, 70% {
opacity: 0;
}
80% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
96% {
opacity: 1;
transform: scale(1.05) rotate(4deg);
}
100% {
opacity: 0;
}
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.blob {
animation: none;
}
.m1 {
opacity: 1;
}
.m2, .m3 {
opacity: 0;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./BlobMorphBg.module.scss";
export type BlobMorphBgProps = {
children?: ReactNode;
/** 세 블롭이 한 바퀴 교차하는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 블롭 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BlobMorphBg({
children,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
className = "",
}: BlobMorphBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<span className={`${styles.blob} ${styles.m1}`} aria-hidden="true" />
<span className={`${styles.blob} ${styles.m2}`} aria-hidden="true" />
<span className={`${styles.blob} ${styles.m3}`} aria-hidden="true" />
{children}
</div>
);
}05 · Blob morph — Measured area 36.54% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password
.m1 { border-radius: 42% 58% 63% 37% / 41% 44% 56% 59%; animation: morph1 $duration $easing infinite; }
.m2 { border-radius: 63% 37% 41% 59% / 55% 48% 52% 45%; opacity: 0; animation: morph2 $duration $easing infinite; }
.m3 { border-radius: 35% 65% 55% 45% / 60% 40% 60% 40%; opacity: 0; animation: morph3 $duration $easing infinite; }
@keyframes morph1 {
0% { opacity: 1; transform: scale(1) rotate(0deg); }
28% { opacity: 1; transform: scale(1.05) rotate(4deg); }
38% { opacity: 0; transform: scale(.94) rotate(8deg); }
92% { opacity: 0; }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
06Noise grain overlay
How it works
A single repeating-conic-gradient tile is painted at just 3 by 3 pixels and tiled edge to edge, then background-position jumps between frames through steps(40) instead of any smoothed easing, so the tiny wedges appear to crawl like real film grain rather than glide. It only ever shifts position, so nothing newer than plain conic-gradient support is needed; animating the gradient's own stop angles directly would call for a registered custom property instead, and @property only reached Chrome 85, Safari 16.4, and Firefox 128.
Where it fits
Drop it under a hero image or a premium card at low opacity for texture, not as a whole background on its own.

<main class="stage">
<p class="hint">Premium card</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 그레인 지글거림이 한 바퀴 도는 시간
$easing: steps(40); // 프레임을 건너뛰어야 그레인처럼 보인다 (부드러운 이징 금지)
$color: $stage-ink; // 그레인 점 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-yellow);
.stage {
position: relative;
overflow: hidden;
}
// 아주 작은 conic-gradient 타일을 반복시키고 background-position 을 steps() 로 매 프레임 건너뛴다
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: repeating-conic-gradient(from 0deg, rgba($color, .55) 0deg 90deg, transparent 90deg 180deg);
background-size: 3px 3px;
opacity: .35;
animation: grain $duration $easing infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .12);
color: $stage-ink;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes grain {
from { background-position: 0 0; }
to { background-position: 117px 69px; }
}
@media (prefers-reduced-motion: reduce) {
.stage::before { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: repeating-conic-gradient(from 0deg, rgba(23, 20, 26, 0.55) 0deg 90deg, transparent 90deg 180deg);
background-size: 3px 3px;
opacity: 0.35;
animation: grain 2s steps(40) infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes grain {
from {
background-position: 0 0;
}
to {
background-position: 117px 69px;
}
}
@media (prefers-reduced-motion: reduce) {
.stage::before {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./NoiseGrainBg.module.scss";
export type NoiseGrainBgProps = {
children?: ReactNode;
/** 그레인 지글거림이 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
/** 그레인 점 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function NoiseGrainBg({
children,
duration = 2000,
color = "#17141a",
className = "",
}: NoiseGrainBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<div className={styles.grain} aria-hidden="true" />
{children}
</div>
);
}06 · Noise grain overlay — Measured area 99.705% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: repeating-conic-gradient(from 0deg, rgba($color, .55) 0deg 90deg, transparent 90deg 180deg);
background-size: 3px 3px;
opacity: .35;
animation: grain $duration $easing infinite;
}
@keyframes grain {
from { background-position: 0 0; }
to { background-position: 117px 69px; }
}
07Drifting dot pattern
How it works
A single radial-gradient dot is tiled into a 20px grid, and background-position drifts diagonally at a constant linear pace across exactly one tile's width and height. The pattern loops without ever showing where one tile ends and the next begins.
Where it fits
It sits well behind a card grid or a slide-deck background, quiet enough not to compete with whatever is printed on top of it.

<main class="stage">
<p class="hint">Slide background</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 점 한 칸만큼 흐르는 시간
$easing: linear;
$color: $subject-cream; // 점 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-orange);
.stage {
position: relative;
overflow: hidden;
}
// radial-gradient 로 찍은 점 패턴을 타일링하고 background-position 만 대각선으로 흘려보낸다
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba($color, .6) 1.5px, transparent 1.5px);
background-size: 20px 20px;
animation: dotdrift $duration $easing infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .25);
color: $stage-paper;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes dotdrift {
from { background-position: 0 0; }
to { background-position: 20px 20px; }
}
@media (prefers-reduced-motion: reduce) {
.stage::before { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba(255, 247, 230, 0.6) 1.5px, transparent 1.5px);
background-size: 20px 20px;
animation: dotdrift 2s linear infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.25);
color: #ffffff;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes dotdrift {
from {
background-position: 0 0;
}
to {
background-position: 20px 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.stage::before {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./DotPatternDriftBg.module.scss";
export type DotPatternDriftBgProps = {
children?: ReactNode;
/** 점 한 칸만큼 흐르는 시간 (기본 2000ms) */
duration?: number;
/** 점 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function DotPatternDriftBg({
children,
duration = 2000,
color = "#fff7e6",
className = "",
}: DotPatternDriftBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<div className={styles.dots} aria-hidden="true" />
{children}
</div>
);
}07 · Drifting dot pattern — Measured area 24.738% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(circle, rgba($color, .6) 1.5px, transparent 1.5px);
background-size: 20px 20px;
animation: dotdrift $duration $easing infinite;
}
@keyframes dotdrift {
from { background-position: 0 0; }
to { background-position: 20px 20px; }
}
08Diagonal stripe move
How it works
The stripes are drawn straight — a plain repeating-linear-gradient running at 90 degrees — inside a layer that is oversized and then rotated a full 45 degrees with transform. Only after that rotation does background-position slide the pre-rotation layer sideways by one stripe-and-gap width.
Where it fits
Building it this way, instead of tilting the gradient itself to 45deg and nudging its position along that angle, is the one real trap in this set — more on why below — and it is what makes the stripes read as marching across a sale banner or a processing-state background instead of standing still.

<main class="stage">
<p class="hint">Promo banner</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 줄무늬 한 칸만큼 행진하는 시간
$easing: linear;
$color: $subject-blue; // 줄무늬 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.stage {
position: relative;
overflow: hidden;
}
// 세로 줄무늬를 그린 뒤 통째로 45도 회전시켜 대각선으로 보이게 하고, background-position 은 회전 전 좌표계에서 가로로만 한 칸(36px) 이동시킨다
// (45deg 로 기울인 채로 background-position 을 대각선으로 움직이면 이동 방향이 줄무늬와 나란해져 화면이 안 바뀐다 — 실측 2026-09-09)
.stage::before {
content: "";
position: absolute;
inset: -150% -150%;
transform: rotate(45deg);
background-image: repeating-linear-gradient(90deg, rgba($color, .18) 0 18px, transparent 18px 36px);
animation: stripemove $duration $easing infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-ink, .08);
color: $stage-ink;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes stripemove {
from { background-position: 0 0; }
to { background-position: 36px 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage::before { animation: 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);
}
.stage {
position: relative;
overflow: hidden;
}
.stage::before {
content: "";
position: absolute;
inset: -150% -150%;
transform: rotate(45deg);
background-image: repeating-linear-gradient(90deg, rgba(47, 109, 246, 0.18) 0 18px, transparent 18px 36px);
animation: stripemove 2s linear infinite;
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@keyframes stripemove {
from {
background-position: 0 0;
}
to {
background-position: 36px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage::before {
animation: none;
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./StripesDiagonalBg.module.scss";
export type StripesDiagonalBgProps = {
children?: ReactNode;
/** 줄무늬 한 칸만큼 행진하는 시간 (기본 2000ms) */
duration?: number;
/** 줄무늬 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
export default function StripesDiagonalBg({
children,
duration = 2000,
color = "#2f6df6",
className = "",
}: StripesDiagonalBgProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
return (
<div className={`${styles.stage} ${className}`} style={vars}>
<div className={styles.stripes} aria-hidden="true" />
{children}
</div>
);
}08 · Diagonal stripe move — Measured area 99.674% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
.stage::before {
content: "";
position: absolute;
inset: -150% -150%;
transform: rotate(45deg);
background-image: repeating-linear-gradient(90deg, rgba($color, .18) 0 18px, transparent 18px 36px);
animation: stripemove $duration $easing infinite;
}
@keyframes stripemove {
from { background-position: 0 0; }
to { background-position: 36px 0; }
}
09Cursor spotlight background
How it works
A single blurred radial-gradient circle acts as the spotlight, and in this demo transform: translate alone carries it around a fixed elliptical path. On a live page the same .spot element swaps that scripted path for the real x and y a pointermove listener reads off the cursor, gated behind a matchMedia('(pointer: fine)') check so touch screens never try to track a pointer that isn't there.
Where it fits
It turns an interactive landing background or the accent lighting on a dark hero into something that visibly reacts to the one person looking at it.

<main class="stage">
<span class="spot is-demo" aria-hidden="true"></span>
<p class="hint">Accent light</p>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 스포트라이트가 궤도를 한 바퀴 도는 시간
$easing: $ease-spring;
$color: $subject-blue; // 스포트라이트 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-ink);
.stage {
position: relative;
overflow: hidden;
}
// 작은 블러 원 하나만 transform: translate 로 궤도를 그린다. 실제 페이지에서는 같은 좌표가 pointermove 에서 온다
.spot {
position: absolute;
top: 50%;
left: 50%;
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
border-radius: 50%;
background: radial-gradient(circle, rgba($color, .55), transparent 70%);
filter: blur(6px);
pointer-events: none;
}
.spot.is-demo { animation: orbit $duration $easing infinite; }
@keyframes orbit {
0% { transform: translate(-90px, -40px); }
25% { transform: translate(90px, -50px); }
50% { transform: translate(70px, 60px); }
75% { transform: translate(-80px, 55px); }
100% { transform: translate(-90px, -40px); }
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: $r-pill;
background: rgba($stage-paper, .12);
color: $stage-paper;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo { animation: none; transform: translate(-90px, -40px); }
}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);
}
.stage {
position: relative;
overflow: hidden;
}
.spot {
position: absolute;
top: 50%;
left: 50%;
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
border-radius: 50%;
background: radial-gradient(circle, rgba(47, 109, 246, 0.55), transparent 70%);
filter: blur(6px);
pointer-events: none;
}
.spot.is-demo {
animation: orbit 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes orbit {
0% {
transform: translate(-90px, -40px);
}
25% {
transform: translate(90px, -50px);
}
50% {
transform: translate(70px, 60px);
}
75% {
transform: translate(-80px, 55px);
}
100% {
transform: translate(-90px, -40px);
}
}
.hint {
position: relative;
margin: 0;
padding: 10px 20px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: #ffffff;
font: 700 clamp(14px, 4vw, 18px)/1.3 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
@media (prefers-reduced-motion: reduce) {
.spot.is-demo {
animation: none;
transform: translate(-90px, -40px);
}
}// 실제 페이지에서는 좌표를 pointermove 값으로 바꿔 스포트라이트가 커서를 따라가게 한다. 데모(.is-demo)는 궤도를 자동으로 돈다.
if (matchMedia('(pointer: fine)').matches) {
var stage = document.querySelector('.stage');
var spot = document.querySelector('.spot');
stage.addEventListener('pointermove', function (e) {
var r = stage.getBoundingClientRect();
spot.classList.remove('is-demo');
spot.style.transform = 'translate(' + (e.clientX - r.left - r.width / 2) + 'px,' + (e.clientY - r.top - r.height / 2) + 'px)';
});
}import { useCallback, useState } from "react";
import type { CSSProperties, PointerEvent, ReactNode } from "react";
import styles from "./SpotlightCursorBg.module.scss";
export type SpotlightCursorBgProps = {
children?: ReactNode;
/** 궤도를 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
easing?: string;
/** 스포트라이트 색 */
color?: string;
className?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpotlightCursorBg({
children,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
className = "",
}: SpotlightCursorBgProps) {
const [demo, setDemo] = useState(true);
const [pos, setPos] = useState({ x: -90, y: -40 });
const move = useCallback((e: PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
setDemo(false);
setPos({ x: e.clientX - r.left - r.width / 2, y: e.clientY - r.top - r.height / 2 });
}, []);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const t = demo ? undefined : { transform: `translate(${pos.x}px,${pos.y}px)` };
return (
<div className={`${styles.stage} ${className}`} style={vars} onPointerMove={move}>
<span className={demo ? `${styles.spot} ${styles.isDemo}` : styles.spot} style={t} aria-hidden="true" />
{children}
</div>
);
}09 · Cursor spotlight background — Measured area 34.851% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
.spot {
position: absolute;
top: 50%;
left: 50%;
width: 120px;
height: 120px;
margin: -60px 0 0 -60px;
border-radius: 50%;
background: radial-gradient(circle, rgba($color, .55), transparent 70%);
filter: blur(6px);
pointer-events: none;
}
.spot.is-demo { animation: orbit $duration $easing infinite; }
@keyframes orbit {
0% { transform: translate(-90px, -40px); }
25% { transform: translate(90px, -50px); }
50% { transform: translate(70px, 60px); }
75% { transform: translate(-80px, 55px); }
100% { transform: translate(-90px, -40px); }
}
Where it breaks — the trap
Item 08 is the one demo in this set that could not be built the obvious way.
Why the diagonal cancels out
The natural instinct is to tilt the repeating-linear-gradient itself to 45deg and then slide background-position along that same diagonal — but a gradient that repeats along an axis and a background-position shift along that identical axis cancel each other out completely. Move it by one full stripe-and-gap and the pattern lands exactly back on itself, so the browser paints something that looks frozen even while the animation is technically running every frame.
Rotate the layer instead
The fix in the demo is to keep the gradient itself perfectly plain — vertical stripes at 90deg, nothing tilted — and instead rotate the whole oversized layer 45 degrees with transform once, up front. background-position then only has to slide sideways in that layer's own pre-rotation coordinate space, which the 45-degree transform carries along for the ride. The stripes visibly march across the stage the way a sale banner needs them to.
What's in the archive
Grab the archive with every one of the nine fixes already wired in — the vertical-stripe trick from item 08 included — by typing bc82wnvk into the prompt exactly as it reads here, all lowercase, no extra characters added. Scaling 02 and 03's drifting color up to whole landing sections is what the aurora mesh gradient kit does.
Accessibility
What each one freezes on
None of the nine goes fully still under prefers-reduced-motion, but each settles on a different frozen frame instead of one blanket rule. 01, 03, 04, 06, 07, and 08 simply stop their animation and hold whatever background-position or transform they were last computing. For a gradient, a grid, or a stripe set, that still reads as a complete, legible background rather than a half-drawn one. 02's three blobs freeze mid-drift in their own separate spots rather than resetting to a single overlapping mass, so the mesh still looks intentional even standing still. 05 goes further and fixes .m2 and .m3 at opacity 0, leaving only the first blob's own border-radius shape visible. Three overlapping static blobs would read as clutter where one clean shape reads as calm.
The pointer-driven outlier
09 is the outlier. The demo's own automatic orbit stops, but on a live page the reduced-motion query never touches the pointermove listener at all, since that motion is a direct response to where a real cursor already is rather than a loop the page invented on its own. MDN documents prefers-reduced-motion and the operating-system toggles that turn it on.
@media (prefers-reduced-motion: reduce) {
.stage::before,
.blob, .aurora,
.m1, .m2, .m3,
.spot.is-demo {
animation: none;
}
}
Browse the other patterns on the CSS hub, and check the about page for the short version of what ships here and what stays out.
FAQ
Is there a css background animation generator built into this set?
Not in the point-and-click sense — every demo compiles from three SCSS variables, usually $duration, $easing, and $color, so changing those three already covers most of what a generator's sliders would let you drag. Swap the color token, stretch the duration, and the same nine patterns rebuild themselves into a different-looking set without touching a single selector.
Do these css background animation examples loop without a visible seam?
Yes, and that is the actual engineering behind most of them. 01, 04, 06, 07, and 08 all move background-position across exactly one full tile, gradient span, or stripe width, so the frame at 100% lines up pixel-for-pixel with the frame at 0% and the restart is invisible. 02, 03, 05, and 09 loop the same way through their transform paths instead — each keyframe's last stop matches its first — so nothing here relies on a hidden reset or a hard cut you would notice on a slow monitor. Filling the ground with drifting pieces rather than color is the route the particle backgrounds set takes.
Can I preview one of these css background animation examples without a build tool?
Yes — every pattern compiles from plain SCSS and three variables, so pasting one demo's block into CodePen or any other SCSS playground previews it with no bundler involved. What you get from the archive carries an MIT license, so whichever of the nine you keep — vanilla markup or the react/ folder — is yours to reuse without crediting this site back.









