CSS 배경 애니메이션 9종 — 한 줄 복붙으로
CSS 배경 애니메이션은 히어로 섹션이나 카드 뒤에서 이미지 파일 없이 그라데이션·격자·점·노이즈 같은 패턴을 계속 움직이게 만드는 기법입니다. 아홉 개를 모았는데, 그중 하나는 커서를 따라가는 스포트라이트라 마우스가 없는 이 격자 안에서는 궤도를 자동으로 돕니다.
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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 그라디언트 흐름
- 02 메시 그라디언트 드리프트
- 03 오로라 블러
- 04 움직이는 격자
- 05 블롭 모핑
- 06 노이즈 그레인
- 07 도트 패턴 흐름
- 08 사선 스트라이프 이동
- 09 커서 스포트라이트 배경
아홉 개 css 배경 애니메이션 효과는 화려한 순서가 아니라 배경이 무대에서 맡는 역할 순서로 놓았습니다. 색 자체가 주인공인 것(01~03), 구조를 그려 보여주는 것(04), 형태가 계속 바뀌는 것(05), 질감을 더하는 것(06~07), 방향성을 주는 것(08), 마지막으로 사용자 입력에 반응하는 것(09)입니다. 전부 background-size를 실제보다 키우거나 요소를 작게 잘라 transform·opacity·background-position만 움직이며, border-radius나 width를 프레임마다 다시 계산하지 않습니다. 항목마다 코드는 핵심 조각만 실었고, 전체 파일은 zip에 있습니다. 움직임이 배경이 아니라 글자에 붙어야 하면 CSS 텍스트 애니메이션 효과가 그 반쪽이고, 배경 위에 얹힌 사진에 반응이 필요하면 CSS 이미지 호버 효과 9종이 짝이 됩니다.
01그라디언트 흐름
css 배경 그라데이션 애니메이션은 어떻게 도나
배경을 화면보다 두 배 크게 깔고 background-position만 오가게 하면 정지된 그라디언트가 물 흐르듯 움직입니다.
어디에 쓰나
별도 요소 없이 무대 배경 자체가 피사체입니다.

<main class="stage">
<p class="hint">히어로 배경</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 · 그라디언트 흐름 — 측정 누적면적 99.739% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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%; }
}
02메시 그라디언트 드리프트
어떻게 움직이나
색이 다른 radial-gradient 얼룩 세 개를 겹치고 각자 다른 시작 지연으로 transform: translate를 돌리면, 얼룩들이 서로 교차하며 유기적으로 섞이는 메시 그라디언트가 됩니다.
어디에 쓰나
최근 SaaS 랜딩에서 자주 보이는 배경입니다.

<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 랜딩</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 · 메시 그라디언트 드리프트 — 측정 누적면적 52.819% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.blob { position: absolute; width: 42%; aspect-ratio: 1; border-radius: 50%; filter: blur(12px); opacity: .9; }
.b1 { background: radial-gradient($color, transparent 65%); animation: drift1 $duration $easing infinite; }
.b2 { background: radial-gradient($subject-cream, transparent 65%); animation: drift2 $duration $easing infinite; animation-delay: -.6s; }
.b3 { background: radial-gradient($stage-ink, transparent 65%); 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); } }
03오로라 블러
어떻게 움직이나
filter: blur로 흐린 색 덩어리 두세 개를 반투명하게 겹치고 transform(이동·회전)으로만 돌리면 오로라가 겹쳐 흐르는 듯한 배경이 됩니다.
어디에 쓰나
다크 모드 히어로에 자주 쓰입니다.

<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">다크 히어로</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 · 오로라 블러 — 측정 누적면적 41.671% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.aurora { position: absolute; width: 38%; aspect-ratio: 1; border-radius: 50%; filter: blur(3px); }
.a1 { background: radial-gradient($subject-cream 0%, $subject-cream 25%, transparent 55%);
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); }
}
04움직이는 격자
어떻게 움직이나
가로선·세로선 linear-gradient 두 개를 한 배경에 겹쳐 격자를 그리고 background-position을 x·y 같은 값으로 함께 옮기면, 격자가 대각선으로 끝없이 흐르는 것처럼 보입니다.
어디에 쓰나
기술 문서나 대시보드 배경에 씁니다.

<main class="stage">
<p class="hint">대시보드 배경</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 · 움직이는 격자 — 측정 누적면적 99.682% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 linear infinite;
}
@keyframes gridmove {
from { background-position: 0 0, 0 0; }
to { background-position: 32px 32px, 32px 32px; }
}
05블롭 모핑
어떻게 움직이나
모양이 다른 블롭 세 개를 겹쳐 놓고 opacity와 transform(scale·rotate)만 번갈아 움직이면, border-radius를 매 프레임 다시 계산하지 않고도 하나의 덩어리가 계속 모양을 바꾸는 것처럼 보입니다.

<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">빈 상태 배경</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 · 블롭 모핑 — 측정 누적면적 36.54% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.blob { position: absolute; width: 52%; aspect-ratio: 1; 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; }
@keyframes morph1 {
0% { opacity: 1; transform: scale(1) rotate(0deg); }
38% { opacity: 0; transform: scale(.94) rotate(8deg); }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
06노이즈 그레인
어떻게 움직이나
아주 작은(3px) repeating-conic-gradient 타일을 반복시키고 background-position을 steps(40) 이징으로 매 프레임 건너뛰면, 부드러운 보간 없이 필름 그레인처럼 지글거립니다.
어디에 쓰나
프리미엄 카드 배경의 질감으로 씁니다.

<main class="stage">
<p class="hint">프리미엄 카드</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 · 노이즈 그레인 — 측정 누적면적 99.705% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 steps(40) infinite;
}
@keyframes grain {
from { background-position: 0 0; }
to { background-position: 117px 69px; }
}
07도트 패턴 흐름
어떻게 움직이나
radial-gradient로 찍은 작은 점을 20px 간격으로 타일링하고 background-position을 대각선으로 천천히 옮기면, 점들이 끊김 없이 흐르는 것처럼 보입니다.
어디에 쓰나
슬라이드나 카드 그리드 뒷배경에 어울립니다.

<main class="stage">
<p class="hint">슬라이드 배경</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 · 도트 패턴 흐름 — 측정 누적면적 24.738% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 linear infinite;
}
@keyframes dotdrift {
from { background-position: 0 0; }
to { background-position: 20px 20px; }
}
08사선 스트라이프 이동
어떻게 움직이나
repeating-linear-gradient(90deg, 색 0 18px, transparent 18px 36px)로 세로 줄무늬를 그린 뒤 레이어 전체를 transform: rotate(45deg)로 기울이고, background-position은 회전 전 좌표계에서 가로로만 옮기면 대각선 줄무늬가 행진하듯 흐릅니다.
어디에 쓰나
세일 배너에 흔한 패턴입니다.

<main class="stage">
<p class="hint">프로모션 배너</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 · 사선 스트라이프 이동 — 측정 누적면적 99.674% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
.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 linear infinite;
}
@keyframes stripemove {
from { background-position: 0 0; }
to { background-position: 36px 0; }
}
09커서 스포트라이트 배경
어떻게 움직이나
블러 처리한 작은 원 하나가 transform: translate로 궤도를 그리며 움직이는 스포트라이트입니다.
어디에 쓰나
이 격자 안에서는 자동으로 돌지만, 실제 페이지에서는 같은 좌표를 pointermove에서 받아 커서를 따라갑니다.

<main class="stage">
<span class="spot is-demo" aria-hidden="true"></span>
<p class="hint">포인트 조명</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 · 커서 스포트라이트 배경 — 측정 누적면적 34.851% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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)';
});
}
어디서 깨지는가 — 함정 하나
왜 transform 과 opacity 만 쓰나
09를 제외한 여덟 개가 전부 transform·opacity·background-position만 움직이는 데는 이유가 있습니다. 배경은 화면의 넓은 면적을 차지하기 때문에, width나 border-radius를 매 프레임 다시 계산하면 브라우저가 그 넓은 영역을 통째로 다시 그려야 합니다. MDN의 애니메이션 성능 문서가 합성 단계에서 처리되는 속성만 권하는 이유입니다.
대각선으로 옮겼더니 멈춰 보였다
08 사선 스트라이프를 처음 만들 때 background-position을 대각선(x·y 같은 값)으로 옮겼더니 화면이 한 프레임도 안 바뀌었습니다 — 45도로 이미 기울인 무늬 위에서 대각선 이동은 줄무늬와 나란한 방향이라 이동 자체가 눈에 보이지 않았던 겁니다. 세로로 그린 다음 레이어를 통째로 회전시키는 순서로 바꾸니 해결됐습니다.
프레임으로 잰 값
함정은 아니지만 덧붙일 것이 하나 더 있습니다. 이 아홉 개 파일을 압축한 zip의 비밀번호는 bc82wnvk이고, 그 안에는 이번에 걸린 수치까지 정리해 뒀습니다. 실제로 04 격자와 08 스트라이프는 배경 전체가 매 프레임 바뀌어 화면의 99% 이상이 움직였고, 02 메시 그라데이션은 얼룩이 덜 겹치는 프레임이 많아 절반 정도만 바뀌었습니다. 뒤 값이 낮다고 이상한 건 아니고, 애초에 그 정도만 움직이도록 설계한 겁니다. 02·03의 흐르는 색을 랜딩 섹션 통째로 키우고 싶다면 그레인 얹은 오로라 그라디언트 랜딩 부품이 그 확장형입니다.
접근성
동작 줄이기에서
운영체제의 "동작 줄이기" 설정을 켜 둔 사람에게는 아홉 개 배경 모두 prefers-reduced-motion: reduce 조건에서 다르게 반응합니다. 배경은 장식이라 움직임만 끄고 색이나 패턴 자체는 마지막 프레임으로 고정해 화면이 비어 보이지 않게 합니다. 05 블롭 모핑은 첫 번째 모양만 남기고 나머지 두 겹은 투명 상태로 고정하며, 09 스포트라이트는 궤도 시작 지점에서 멈춥니다. 이 값을 브라우저마다 어떻게 읽는지는 MDN의 prefers-reduced-motion 문서에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.stage::before,
.blob,
.aurora,
.spot.is-demo { animation: none; }
}
글자 대비도 같이 본다
배경이 넓은 면적을 덮는 만큼 그 위에 얹는 글자의 대비도 함께 봐야 합니다. 09처럼 스포트라이트가 밝기를 바꾸는 배경이라면 텍스트 색을 스포트라이트 색과 별개로 고정해 대비가 흔들리지 않게 해야 합니다.
배경 말고 버튼·드롭다운처럼 화면 앞쪽이 움직이는 사례는 CSS 애니메이션 모음에서 골라볼 수 있고, 이 사이트가 어떤 기준으로 데모를 고르는지는 소개 페이지에 적어 뒀습니다.
FAQ
CSS 배경 애니메이션이 페이지 성능에 부담을 주나요?
transform·opacity·background-position만 쓰면 합성 단계에서 처리되어 부담이 크지 않습니다. 다만 배경은 화면 전체를 덮는 경우가 많아 filter: blur를 스테이지 전체에 걸면 다른 항목보다 비용이 커집니다. 그래서 02·03처럼 블러가 필요한 항목은 작은 요소 몇 개에만 걸고 무대 전체엔 걸지 않았습니다. 배경을 색이 아니라 흩날리는 조각으로 채우고 싶다면 CSS 배경 애니메이션 효과 모음 9종이 그 자리의 다른 답입니다.
09 스포트라이트가 이 페이지에서는 왜 자동으로 도나요?
격자 안 데모는 마우스를 올리지 않아도 움직여야 렌더·측정이 가능해서 .is-demo 클래스로 자동 궤도를 강제했습니다. zip을 받아 실제 페이지에 붙이면 pointer: fine 기기에서 pointermove 좌표가 그 자리를 대신해 스포트라이트가 커서를 따라갑니다.
css 배경 패턴을 아홉 개나 한 페이지에 다 써도 되나요?
기술적으로는 가능하지만 한 화면에는 하나만 쓰는 걸 권합니다. 배경 두 개가 동시에 움직이면 어느 쪽이 신호인지 알 수 없고, 그 위에 얹은 글자도 읽기 어려워집니다. 06 노이즈 그레인처럼 질감을 더하는 것과 01 그라디언트 흐름처럼 색 자체가 주인공인 것을 같이 쓰는 조합도 피하는 게 좋습니다.









