CSS 글로우 효과 9종 — 네온사인처럼 빛나는 버튼과 텍스트
CSS 글로우 효과는 버튼이나 글자 주위에 빛이 번지는 것처럼 보이게 만드는 장식이다. 네온사인처럼 빛나 보이지만 이미지나 동영상이 아니라 코드 몇 줄로 만든다. 한 줄 복붙으로 그대로 옮겨 쓸 수 있는 CSS 글로우 효과 9가지를 모았다.
01 glow-neon-effect-text-neon-flicker
02 glow-neon-effect-button-glow-hover
03 glow-neon-effect-border-glow-pulse
04 glow-neon-effect-card-glow-spotlight
06 glow-neon-effect-underline-glow
07 glow-neon-effect-property-glow-cycle
08 glow-neon-effect-glow-light-dark
09 glow-neon-effect-glow-scroll-intensity
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 네온 텍스트 깜빡임
- 02 버튼 글로우 호버
- 03 보더 글로우 펄스
- 04 카드 스포트라이트 글로우
- 05 아이콘 글로우 링
- 06 밑줄 글로우
- 07 @property 색상 순환 글로우
- 08 light-dark() 대응 글로우
- 09 스크롤에 따른 글로우 강도
아홉 개는 인기순이 아니라 화면에서 글로우가 실제로 겪는 순서로 놓았다. 눈에 띄는 텍스트(01)로 시작해 누르는 버튼(02)·기다리는 상태 카드(03)·강조가 필요한 카드(04)·알림 아이콘(05)·메뉴 탐색(06)을 지나, 계속 튀어야 하는 배지(07)·다크모드에 맞춰야 하는 배지(08)를 거쳐 페이지에 들어오며 등장하는 카드(09)로 끝난다. 같은 빛을 경고 신호로 쓰고 싶다면 흔들리는 애니메이션이 그 자리에 더 맞고, 빛을 칸 단위로 나눠 배치하는 구성은 벤토 그리드 호버가 보여 줍니다. 09처럼 등장과 동시에 빛나는 카드까지 오면 빛이 문서에서 맡는 구실이 전부 지나가게 됩니다.
01네온 텍스트 깜빡임
어떻게 보이나
글자에 겹친 다섯 겹의 text-shadow 가 네온사인처럼 번지고, 규칙적으로 켜졌다 꺼지면 가짜 티가 나서 @keyframes 정지 구간을 13개로 쪼개 불규칙하게 깜빡이게 했다.

<main class="stage is-demo">
<h1 class="neon">GODRICH</h1>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 깜빡임 한 주기
$easing: linear;
$color: #ff2e63; // 네온 핑크
@include stage($stage-ink);
.neon {
margin: 0;
font: 800 clamp(30px, 11vmin, 56px)/1 "Segoe UI", -apple-system, "Noto Sans KR", sans-serif;
letter-spacing: .04em;
color: $stage-paper;
text-shadow:
0 0 $sp-1 $color,
0 0 $sp-2 $color,
0 0 $sp-4 rgba($color, .8),
0 0 $sp-8 rgba($color, .6),
0 0 $sp-12 rgba($color, .35);
}
// opacity 하나만 흔들어도 색+text-shadow 전체가 같이 어두워진다 — transform·opacity 원칙 유지
.stage.is-demo .neon {
animation: flicker $duration $easing infinite;
}
@keyframes flicker {
0% { opacity: 1; }
6% { opacity: .3; }
14% { opacity: 1; }
20% { opacity: 1; }
26% { opacity: .4; }
34% { opacity: 1; }
50% { opacity: 1; }
56% { opacity: .25; }
64% { opacity: 1; }
80% { opacity: 1; }
86% { opacity: .35; }
94% { opacity: 1; }
100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .neon { animation: 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.neon {
margin: 0;
font: 800 clamp(30px, 11vmin, 56px)/1 "Segoe UI", -apple-system, "Noto Sans KR", sans-serif;
letter-spacing: 0.04em;
color: #ffffff;
text-shadow: 0 0 4px #ff2e63, 0 0 8px #ff2e63, 0 0 16px rgba(255, 46, 99, 0.8), 0 0 32px rgba(255, 46, 99, 0.6), 0 0 48px rgba(255, 46, 99, 0.35);
}
.stage.is-demo .neon {
animation: flicker 2s linear infinite;
}
@keyframes flicker {
0% {
opacity: 1;
}
6% {
opacity: 0.3;
}
14% {
opacity: 1;
}
20% {
opacity: 1;
}
26% {
opacity: 0.4;
}
34% {
opacity: 1;
}
50% {
opacity: 1;
}
56% {
opacity: 0.25;
}
64% {
opacity: 1;
}
80% {
opacity: 1;
}
86% {
opacity: 0.35;
}
94% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .neon {
animation: none;
opacity: 1;
}
}import type { CSSProperties } from "react";
import styles from "./NeonTextFlicker.module.scss";
export type NeonTextFlickerProps = {
/** 화면에 뜨는 글자 (기본 "GODRICH") */
text?: string;
/** 깜빡임 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function NeonTextFlicker({
text = "GODRICH",
duration = 2000,
color = "#ff2e63",
}: NeonTextFlickerProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.stage} style={vars}>
<h1 className={styles.neon}>{text}</h1>
</div>
);
}01 · 네온 텍스트 깜빡임 — 측정 누적면적 6.513% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
무엇이 핵심인가
.neon {
text-shadow:
0 0 $sp-1 $color,
0 0 $sp-2 $color,
0 0 $sp-4 rgba($color, .8),
0 0 $sp-8 rgba($color, .6),
0 0 $sp-12 rgba($color, .35);
}
.stage.is-demo .neon { animation: flicker $duration $easing infinite; }
@keyframes flicker {
0% { opacity: 1; }
6% { opacity: .3; }
14% { opacity: 1; }
20% { opacity: 1; }
26% { opacity: .4; }
34% { opacity: 1; }
50% { opacity: 1; }
56% { opacity: .25; }
64% { opacity: 1; }
80% { opacity: 1; }
86% { opacity: .35; }
94% { opacity: 1; }
100% { opacity: 1; }
}
02버튼 글로우 호버
css 버튼 호버 애니메이션으로 어디에 쓰나
버튼 뒤에 깔린 흐릿한 box-shadow 가 평소엔 옅게 있다가, 마우스를 올리거나 키보드로 포커스를 주면 반경과 밝기가 커진다.

<main class="stage is-demo">
<button class="btn" type="button" aria-label="다음">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 호버 루프 한 주기
$easing: $ease-out;
$color: #00e5ff; // 네온 시안
@include stage($stage-ink);
.btn {
display: grid; place-items: center;
width: $sp-12; height: $sp-12;
border: 0; border-radius: 50%;
background: rgba($stage-paper, .06);
color: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
box-shadow: 0 0 $sp-2 rgba($color, .3), 0 0 $sp-4 rgba($color, .12);
transition: box-shadow $dur-base $easing, transform $dur-base $easing, background $dur-base $easing;
}
.btn:hover,
.btn:focus-visible {
background: rgba($stage-paper, .1);
box-shadow: 0 0 $sp-3 $color, 0 0 $sp-8 rgba($color, .65), 0 0 $sp-12 rgba($color, .3);
transform: scale(1.06);
outline: none;
}
// box-shadow·transform 만 바뀐다 — 레이아웃은 그대로
.stage.is-demo .btn {
animation: hover-loop $duration $easing infinite;
}
@keyframes hover-loop {
0%, 12%, 100% {
box-shadow: 0 0 $sp-2 rgba($color, .3), 0 0 $sp-4 rgba($color, .12);
transform: scale(1);
}
35%, 75% {
box-shadow: 0 0 $sp-3 $color, 0 0 $sp-8 rgba($color, .65), 0 0 $sp-12 rgba($color, .3);
transform: scale(1.06);
}
92% {
box-shadow: 0 0 $sp-2 rgba($color, .3), 0 0 $sp-4 rgba($color, .12);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn { animation: none; }
.btn { transition-duration: 1ms; }
}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);
}
.btn {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
color: #00e5ff;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 0 8px rgba(0, 229, 255, 0.3), 0 0 16px rgba(0, 229, 255, 0.12);
transition: box-shadow 300ms ease-out, transform 300ms ease-out, background 300ms ease-out;
}
.btn:hover,
.btn:focus-visible {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 12px #00e5ff, 0 0 32px rgba(0, 229, 255, 0.65), 0 0 48px rgba(0, 229, 255, 0.3);
transform: scale(1.06);
outline: none;
}
.stage.is-demo .btn {
animation: hover-loop 2s ease-out infinite;
}
@keyframes hover-loop {
0%, 12%, 100% {
box-shadow: 0 0 8px rgba(0, 229, 255, 0.3), 0 0 16px rgba(0, 229, 255, 0.12);
transform: scale(1);
}
35%, 75% {
box-shadow: 0 0 12px #00e5ff, 0 0 32px rgba(0, 229, 255, 0.65), 0 0 48px rgba(0, 229, 255, 0.3);
transform: scale(1.06);
}
92% {
box-shadow: 0 0 8px rgba(0, 229, 255, 0.3), 0 0 16px rgba(0, 229, 255, 0.12);
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .btn {
animation: none;
}
.btn {
transition-duration: 1ms;
}
}// 실제 사람이 누르면 강제 루프를 떼고 :hover · :focus-visible 에 맡긴다.
document.querySelector('.btn').addEventListener('pointerdown', function () {
document.querySelector('.stage').classList.remove('is-demo');
});import type { CSSProperties, MouseEvent } from "react";
import styles from "./ButtonGlowHover.module.scss";
export type ButtonGlowHoverProps = {
duration?: number;
easing?: string;
color?: string;
label?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
};
export default function ButtonGlowHover({
duration = 300,
easing = "ease-out",
color = "#00e5ff",
label = "다음",
onClick,
}: ButtonGlowHoverProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<button className={styles.btn} type="button" style={vars} aria-label={label} onClick={onClick}>
<svg viewBox="0 0 24 24" width={22} height={22} fill="none" aria-hidden="true">
<path d="M5 12h13M13 6l6 6-6 6" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}02 · 버튼 글로우 호버 — 측정 누적면적 4.303% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
짚어 둘 점
.btn {
box-shadow: 0 0 8px rgba(0, 229, 255, .3),
0 0 16px rgba(0, 229, 255, .12);
transition: box-shadow .3s ease-out, transform .3s ease-out;
}
.btn:hover,
.btn:focus-visible {
box-shadow: 0 0 12px #00e5ff,
0 0 32px rgba(0, 229, 255, .65);
transform: scale(1.06);
}
03보더 글로우 펄스
어떤 화면인가
카드 테두리를 감싼 box-shadow 링이 반경과 불투명도를 오가며 숨쉬듯 커졌다 작아져, 지금 이 카드가 살아 있다는 신호를 준다.

<main class="stage is-demo">
<div class="card">
<span class="dot" aria-hidden="true"></span>
<span class="bar bar--lg" aria-hidden="true"></span>
<span class="bar bar--sm" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 펄스 한 주기
$easing: ease-in-out;
$color: #39ff88; // 네온 그린 (라이브 상태)
@include stage($stage-ink);
.card {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vmin, 260px);
padding: $sp-6 $sp-5;
border-radius: $r-card;
border: 1px solid rgba($color, .5);
background: rgba($stage-paper, .04);
display: flex; flex-direction: column; gap: $sp-3;
}
.dot {
width: $sp-2; height: $sp-2; border-radius: 50%;
background: $color;
box-shadow: 0 0 $sp-2 $color;
}
.bar { display: block; height: $sp-2; border-radius: $r-xs; background: rgba($stage-paper, .18); }
.bar--lg { width: 70%; }
.bar--sm { width: 45%; }
// box-shadow 스프레드·불투명도만 바뀐다 — 테두리 위치·카드 크기는 고정
.stage.is-demo .card {
animation: pulse $duration $easing infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 rgba($color, 0), 0 0 $sp-2 rgba($color, .18);
border-color: rgba($color, .35);
}
50% {
box-shadow: 0 0 $sp-1 rgba($color, .7), 0 0 $sp-8 rgba($color, .55), 0 0 $sp-12 rgba($color, .22);
border-color: rgba($color, .9);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .card { animation: none; box-shadow: 0 0 $sp-2 rgba($color, .4); }
}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);
}
.card {
position: relative;
box-sizing: border-box;
width: clamp(180px, 56vmin, 260px);
padding: 24px 20px;
border-radius: 16px;
border: 1px solid rgba(57, 255, 136, 0.5);
background: rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
gap: 12px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #39ff88;
box-shadow: 0 0 8px #39ff88;
}
.bar {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.bar--lg {
width: 70%;
}
.bar--sm {
width: 45%;
}
.stage.is-demo .card {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 0 rgba(57, 255, 136, 0), 0 0 8px rgba(57, 255, 136, 0.18);
border-color: rgba(57, 255, 136, 0.35);
}
50% {
box-shadow: 0 0 4px rgba(57, 255, 136, 0.7), 0 0 32px rgba(57, 255, 136, 0.55), 0 0 48px rgba(57, 255, 136, 0.22);
border-color: rgba(57, 255, 136, 0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .card {
animation: none;
box-shadow: 0 0 8px rgba(57, 255, 136, 0.4);
}
}import type { CSSProperties, ReactNode } from "react";
import styles from "./BorderGlowPulse.module.scss";
export type BorderGlowPulseProps = {
/** 펄스 한 주기 (기본 2000ms) */
duration?: number;
color?: string;
/** 카드 안에 넣을 내용. 기본은 뼈대(스켈레톤) 두 줄 */
children?: ReactNode;
};
export default function BorderGlowPulse({
duration = 2000,
color = "#39ff88",
children,
}: BorderGlowPulseProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.card} style={vars}>
<span className={styles.dot} aria-hidden="true" />
{children ?? (
<>
<span className={`${styles.bar} ${styles.barLg}`} />
<span className={`${styles.bar} ${styles.barSm}`} />
</>
)}
</div>
);
}03 · 보더 글로우 펄스 — 측정 누적면적 10.727% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어떻게 만들었나
.card { animation: pulse 2s ease-in-out infinite; }
@keyframes pulse {
0%, 100% {
box-shadow: 0 0 8px rgba(57, 255, 136, .18);
border-color: rgba(57, 255, 136, .35);
}
50% {
box-shadow: 0 0 4px rgba(57, 255, 136, .7),
0 0 32px rgba(57, 255, 136, .55);
border-color: rgba(57, 255, 136, .9);
}
}
04카드 스포트라이트 글로우
무엇이 움직이나
카드 위 radial-gradient 의 중심을 --x --y 두 변수가 옮긴다. 이 둘을 @property 로 각도가 아니라 퍼센트 값으로 등록해 뒀기 때문에 @keyframes 가 매끄럽게 보간하고, 커서가 없을 때는 --x --y 를 keyframes 가 대신 움직인다.

<main class="stage is-demo">
<div class="card">
<span class="thumb" aria-hidden="true"></span>
<span class="bar bar--lg" aria-hidden="true"></span>
<span class="bar bar--sm" aria-hidden="true"></span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 스포트라이트 한 바퀴
$easing: ease-in-out;
$color: #b36bff; // 네온 바이올렛
@include stage($stage-ink);
// 등록 없는 커스텀 프로퍼티는 키프레임으로 보간이 안 된다 — 반드시 @property 로 등록한다 (원리 섹션 재료)
@property --x { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
.card {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: clamp(200px, 62vmin, 300px);
aspect-ratio: 4 / 3;
border-radius: $r-card;
background: $stage-ink;
border: 1px solid rgba($stage-paper, .1);
padding: $sp-4;
display: flex; flex-direction: column; justify-content: flex-end; gap: $sp-2;
}
.card::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(circle at var(--x) var(--y), rgba($color, .55), transparent 55%);
pointer-events: none;
}
.thumb { position: absolute; top: $sp-4; left: $sp-4; right: $sp-4; height: 46%; border-radius: $r-sm; background: rgba($stage-paper, .08); }
.bar { display: block; height: $sp-2; border-radius: $r-xs; background: rgba($stage-paper, .18); }
.bar--lg { width: 65%; }
.bar--sm { width: 40%; }
// --x·--y 만 움직인다 — @property 등록 덕에 keyframes 가 보간할 수 있다
.stage.is-demo .card::before {
animation: sweep $duration $easing infinite;
}
@keyframes sweep {
0% { --x: 18%; --y: 20%; }
25% { --x: 85%; --y: 22%; }
50% { --x: 78%; --y: 80%; }
75% { --x: 20%; --y: 76%; }
100% { --x: 18%; --y: 20%; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .card::before { animation: none; --x: 50%; --y: 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);
}
@property --x {
syntax: "<percentage>";
inherits: false;
initial-value: 50%;
}
@property --y {
syntax: "<percentage>";
inherits: false;
initial-value: 50%;
}
.card {
position: relative;
overflow: hidden;
box-sizing: border-box;
width: clamp(200px, 62vmin, 300px);
aspect-ratio: 4/3;
border-radius: 16px;
background: #17141a;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 16px;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--x) var(--y), rgba(179, 107, 255, 0.55), transparent 55%);
pointer-events: none;
}
.thumb {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
height: 46%;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
}
.bar {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.bar--lg {
width: 65%;
}
.bar--sm {
width: 40%;
}
.stage.is-demo .card::before {
animation: sweep 2s ease-in-out infinite;
}
@keyframes sweep {
0% {
--x: 18%;
--y: 20%;
}
25% {
--x: 85%;
--y: 22%;
}
50% {
--x: 78%;
--y: 80%;
}
75% {
--x: 20%;
--y: 76%;
}
100% {
--x: 18%;
--y: 20%;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .card::before {
animation: none;
--x: 50%;
--y: 50%;
}
}// 실제 커서가 카드 위를 지나가면 --x·--y 를 좌표로 갱신하고 데모 루프를 뗀다 (8줄).
(function () {
var card = document.querySelector('.card');
card.addEventListener('pointermove', function (e) {
var r = card.getBoundingClientRect();
card.style.setProperty('--x', ((e.clientX - r.left) / r.width * 100) + '%');
card.style.setProperty('--y', ((e.clientY - r.top) / r.height * 100) + '%');
document.querySelector('.stage').classList.remove('is-demo');
});
})();import { useCallback, useRef } from "react";
import type { CSSProperties, PointerEvent } from "react";
import styles from "./CardSpotlightGlow.module.scss";
export type CardSpotlightGlowProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function CardSpotlightGlow({
duration = 2000,
easing = "ease-in-out",
color = "#b36bff",
}: CardSpotlightGlowProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
const onPointerMove = useCallback((e: PointerEvent<HTMLDivElement>) => {
const el = cardRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
el.style.setProperty("--x", `${((e.clientX - r.left) / r.width) * 100}%`);
el.style.setProperty("--y", `${((e.clientY - r.top) / r.height) * 100}%`);
}, []);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<div ref={cardRef} className={styles.card} style={vars} onPointerMove={onPointerMove}>
<span className={styles.thumb} aria-hidden="true" />
<span className={`${styles.bar} ${styles.barLg}`} />
<span className={`${styles.bar} ${styles.barSm}`} />
</div>
);
}04 · 카드 스포트라이트 글로우 — 측정 누적면적 20.233% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
실제 마우스 위치는 8줄짜리 JS 가 같은 변수에 채워 넣는다.
@property --x { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
@property --y { syntax: '<percentage>'; inherits: false; initial-value: 50%; }
.card::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(circle at var(--x) var(--y),
rgba(179, 107, 255, .55), transparent 55%);
animation: sweep 2s ease-in-out infinite;
}
@keyframes sweep {
0% { --x: 18%; --y: 20%; }
25% { --x: 85%; --y: 22%; }
50% { --x: 78%; --y: 80%; }
75% { --x: 20%; --y: 76%; }
100% { --x: 18%; --y: 20%; }
}
05아이콘 글로우 링
어디에 쓰나
아이콘을 감싼 원 두 개가 번갈아 커지며 옅어지는 걸 반복해, 알림이 퍼져나가는 것처럼 보인다.
<main class="stage is-demo">
<span class="icon-wrap" aria-hidden="true">
<span class="ring ring--a"></span>
<span class="ring ring--b"></span>
<span class="icon"></span>
</span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 링 한 번 퍼지는 시간
$easing: ease-out;
$color: #ffb020; // 네온 앰버 (알림)
@include stage($stage-ink);
.icon-wrap { position: relative; width: 72px; height: 72px; display: grid; place-items: center; }
.icon {
width: 30px; height: 30px; border-radius: 50%;
background: $color;
box-shadow: 0 0 $sp-2 $color, 0 0 $sp-6 rgba($color, .6);
}
.ring {
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid $color;
opacity: 0;
}
// transform·opacity 만 — 이 항목은 순수하게 원칙대로다
.stage.is-demo .ring--a { animation: ring $duration $easing infinite; }
.stage.is-demo .ring--b { animation: ring $duration $easing infinite; animation-delay: $duration * .5; }
@keyframes ring {
0% { transform: scale(.7); opacity: .8; }
70% { transform: scale(1.7); opacity: 0; }
100% { transform: scale(1.7); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ring--a, .stage.is-demo .ring--b { animation: none; 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);
}
.icon-wrap {
position: relative;
width: 72px;
height: 72px;
display: grid;
place-items: center;
}
.icon {
width: 30px;
height: 30px;
border-radius: 50%;
background: #ffb020;
box-shadow: 0 0 8px #ffb020, 0 0 24px rgba(255, 176, 32, 0.6);
}
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid #ffb020;
opacity: 0;
}
.stage.is-demo .ring--a {
animation: ring 2s ease-out infinite;
}
.stage.is-demo .ring--b {
animation: ring 2s ease-out infinite;
animation-delay: 1s;
}
@keyframes ring {
0% {
transform: scale(0.7);
opacity: 0.8;
}
70% {
transform: scale(1.7);
opacity: 0;
}
100% {
transform: scale(1.7);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .ring--a, .stage.is-demo .ring--b {
animation: none;
opacity: 0;
}
}import type { CSSProperties } from "react";
import styles from "./IconGlowRing.module.scss";
export type IconGlowRingProps = {
/** 링 한 번 퍼지는 시간 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function IconGlowRing({ duration = 2000, color = "#ffb020" }: IconGlowRingProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<span className={styles.wrap} style={vars} aria-hidden="true">
<span className={`${styles.ring} ${styles.ringA}`} />
<span className={`${styles.ring} ${styles.ringB}`} />
<span className={styles.icon} />
</span>
);
}05 · 아이콘 글로우 링 — 측정 누적면적 6.007% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드의 요점
transform 과 opacity 만 바뀌어서 이 항목이 이 글에서 가장 가볍게 돈다.
.ring {
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid #ffb020;
opacity: 0;
animation: ring 2s ease-out infinite;
}
.ring--b { animation-delay: 1s; }
@keyframes ring {
0% { transform: scale(.7); opacity: .8; }
70% { transform: scale(1.7); opacity: 0; }
100% { transform: scale(1.7); opacity: 0; }
}
06밑줄 글로우
눈에 보이는 것
네온색 그라데이션이 깔린 밑줄이 고른 메뉴 아래까지 미끄러진다.

<main class="stage is-demo">
<nav class="nav" aria-hidden="true">
<span class="tab"></span>
<span class="tab"></span>
<span class="indicator"></span>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 왕복 한 번
$easing: $ease-spring;
$color: #2df5c0; // 네온 틸
@include stage($stage-ink);
// 탭 두 개가 같은 고정 폭이라 인디케이터는 그 폭+간격만큼만 옮기면 된다 (짐작 없는 값 — BD19)
$tab-w: 96px;
$tab-gap: $sp-4;
.nav { position: relative; display: flex; gap: $tab-gap; }
.tab {
flex: 0 0 auto;
width: $tab-w; height: $sp-8;
border-radius: $r-sm;
background: rgba($stage-paper, .1);
}
.indicator {
position: absolute; left: 0; bottom: -$sp-2;
width: $tab-w; height: 3px;
border-radius: $r-xs;
background: linear-gradient(90deg, transparent, $color, transparent);
box-shadow: 0 0 $sp-3 $color, 0 0 $sp-6 rgba($color, .6);
}
// translateX 만 움직인다 — 폭이 고정값이라 도착 지점도 고정값이다
.stage.is-demo .indicator {
animation: slide $duration $easing infinite;
}
@keyframes slide {
0%, 40% { transform: translateX(0); }
60%, 90% { transform: translateX(#{$tab-w + $tab-gap}); }
100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .indicator { animation: 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);
}
.nav {
position: relative;
display: flex;
gap: 16px;
}
.tab {
flex: 0 0 auto;
width: 96px;
height: 32px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
}
.indicator {
position: absolute;
left: 0;
bottom: -8px;
width: 96px;
height: 3px;
border-radius: 4px;
background: linear-gradient(90deg, transparent, #2df5c0, transparent);
box-shadow: 0 0 12px #2df5c0, 0 0 24px rgba(45, 245, 192, 0.6);
}
.stage.is-demo .indicator {
animation: slide 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes slide {
0%, 40% {
transform: translateX(0);
}
60%, 90% {
transform: translateX(112px);
}
100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .indicator {
animation: none;
}
}import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./UnderlineGlow.module.scss";
export type UnderlineGlowProps = {
/** 슬라이드 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
labels?: [string, string];
};
export default function UnderlineGlow({
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2df5c0",
labels = ["탭 1", "탭 2"],
}: UnderlineGlowProps) {
const [active, setActive] = useState(0);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color } as CSSProperties;
return (
<nav className={styles.nav} style={vars}>
{labels.map((label, i) => (
<button
key={label}
type="button"
className={styles.tab}
aria-current={active === i}
onClick={() => setActive(i)}
>
{label}
</button>
))}
<span className={styles.indicator} data-active={active} />
</nav>
);
}06 · 밑줄 글로우 — 측정 누적면적 3.618% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
동작 원리
탭 두 개를 같은 96px 폭으로 고정해 뒀기 때문에, 이동 거리(96px + 간격 16px)는 렌더 결과를 재보지 않아도 계산으로 정확하다.
.tab { flex: 0 0 auto; width: $tab-w; }
.indicator {
position: absolute; left: 0; bottom: -$sp-2;
width: $tab-w; height: 3px;
background: linear-gradient(90deg, transparent, $color, transparent);
box-shadow: 0 0 $sp-3 $color, 0 0 $sp-6 rgba($color, .6);
}
// translateX 만 움직인다 — 폭이 고정값이라 도착 지점도 고정값이다
.stage.is-demo .indicator { animation: slide $duration $easing infinite; }
@keyframes slide {
0%, 40% { transform: translateX(0); }
60%, 90% { transform: translateX(#{$tab-w + $tab-gap}); } // 96px 폭 + 16px 간격
100% { transform: translateX(0); }
}
07@property 색상 순환 글로우
어떤 상황인가
--hue 를 @property 로 각도(숫자) 타입으로 등록하고 0에서 360까지 돌리면 hsl(var(--hue) 90% 60%) 색이 매끄럽게 이어진다.

<main class="stage is-demo">
<span class="badge" aria-hidden="true"></span>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 색상 한 바퀴
$easing: linear;
$color: #8b5cf6; // @property 미지원 브라우저용 대체 색(첫 색조와 맞춤)
@include stage($stage-ink);
// 등록 없는 커스텀 프로퍼티는 숫자로 안 보이기 때문에 hsl() 이 튄다 — @property 로 <number> 를 선언해야 보간된다
@property --hue { syntax: '<number>'; inherits: false; initial-value: 262; }
.badge {
display: block;
width: 80px; height: 80px;
border-radius: 50%;
background: $stage-ink;
// @supports 로 감싸지 않아도 @property 를 모르는 엔진은 --hue 를 그냥 무시하고 이 고정값을 쓴다
box-shadow: 0 0 $sp-4 $color, 0 0 $sp-10 rgba($color, .5);
}
@supports (background: hsl(var(--hue) 100% 50%)) {
.badge {
box-shadow: 0 0 $sp-4 hsl(var(--hue) 90% 62%), 0 0 $sp-10 hsl(var(--hue) 90% 55% / .55);
}
}
// --hue 하나만 0→360 으로 돈다 — box-shadow 색이 같이 보간된다
.stage.is-demo .badge {
animation: cycle $duration $easing infinite;
}
@keyframes cycle {
from { --hue: 0; }
to { --hue: 360; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge { animation: none; --hue: 262; }
}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);
}
@property --hue {
syntax: "<number>";
inherits: false;
initial-value: 262;
}
.badge {
display: block;
width: 80px;
height: 80px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 0 16px #8b5cf6, 0 0 40px rgba(139, 92, 246, 0.5);
}
@supports (background: hsl(var(--hue), 100%, 50%)) {
.badge {
box-shadow: 0 0 16px hsl(var(--hue), 90%, 62%), 0 0 40px hsl(var(--hue), 90%, 55%, 0.55);
}
}
.stage.is-demo .badge {
animation: cycle 2s linear infinite;
}
@keyframes cycle {
from {
--hue: 0;
}
to {
--hue: 360;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge {
animation: none;
--hue: 262;
}
}import type { CSSProperties } from "react";
import styles from "./PropertyGlowCycle.module.scss";
export type PropertyGlowCycleProps = {
/** 색상 한 바퀴 도는 시간 (기본 2000ms) */
duration?: number;
/** @property 미지원 브라우저용 대체 색 */
color?: string;
};
export default function PropertyGlowCycle({ duration = 2000, color = "#8b5cf6" }: PropertyGlowCycleProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return <span className={styles.badge} style={vars} aria-hidden="true" />;
}07 · @property 색상 순환 글로우 — 측정 누적면적 4.603% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
만드는 방식
등록 없는 커스텀 프로퍼티는 이 보간이 안 돼 색이 중간 없이 툭 튄다.
@property --hue { syntax: '<number>'; inherits: false; initial-value: 262; }
.badge {
box-shadow: 0 0 16px hsl(var(--hue) 90% 62%),
0 0 40px hsl(var(--hue) 90% 55% / .55);
animation: cycle 2s linear infinite;
}
@keyframes cycle {
from { --hue: 0; }
to { --hue: 360; }
}
08light-dark() 대응 글로우
무슨 일이 일어나나
color-scheme 을 선언한 요소 안에서 light-dark() 로 글로우 색을 적으면, 미디어쿼리 없이도 밝은 배경에선 옅게 어두운 배경에선 짙게 한 줄이 알아서 갈린다.

<main class="stage is-demo">
<div class="row" aria-hidden="true">
<div class="pane pane--light"><span class="badge"></span></div>
<div class="pane pane--dark"><span class="badge"></span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 숨쉬기 한 번
$easing: ease-in-out;
$color: #7c5cff; // 네온 인디고
@include stage($stage-ink);
.row { display: flex; gap: $sp-6; }
.pane {
box-sizing: border-box;
width: clamp(96px, 26vmin, 130px);
aspect-ratio: 1;
border-radius: $r-card;
display: grid; place-items: center;
}
// color-scheme 은 요소 단위로도 걸린다 — light-dark() 는 이 값을 보고 고른다
.pane--light { background: $stage-paper; color-scheme: light; }
.pane--dark { background: $stage-ink; color-scheme: dark; border: 1px solid rgba($stage-paper, .1); }
.badge {
width: 42px; height: 42px; border-radius: 50%;
background: light-dark(#e9e2ff, #1c1630);
// light-dark() 는 색 값에만 쓴다 — 길이(px)에 쓰면 무효한 선언이라 box-shadow 전체가 버려진다 (함정 섹션 재료, 실측)
--glow-rest: #{light-dark(rgba($color, 0), rgba($color, .2))};
--glow-peak: #{light-dark(rgba($color, .7), $color)};
--glow-peak-soft: #{light-dark(rgba($color, .3), rgba($color, .7))};
// 미디어쿼리 없이 한 줄이 배경에 맞춰 옅게/짙게 갈린다
box-shadow: 0 0 $sp-1 var(--glow-rest);
}
// box-shadow 가 거의 안 보이는 상태와 짙은 상태를 짧게 오간다 — 전환을 좁게 몰아야 눈에 확실히 띈다
.stage.is-demo .badge {
animation: breathe $duration $easing infinite;
}
@keyframes breathe {
0%, 15%, 85%, 100% { box-shadow: 0 0 $sp-1 var(--glow-rest); }
35%, 65% { box-shadow: 0 0 $sp-4 var(--glow-peak), 0 0 $sp-10 var(--glow-peak-soft); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge { animation: 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);
}
.row {
display: flex;
gap: 24px;
}
.pane {
box-sizing: border-box;
width: clamp(96px, 26vmin, 130px);
aspect-ratio: 1;
border-radius: 16px;
display: grid;
place-items: center;
}
.pane--light {
background: #ffffff;
color-scheme: light;
}
.pane--dark {
background: #17141a;
color-scheme: dark;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.badge {
width: 42px;
height: 42px;
border-radius: 50%;
background: light-dark(#e9e2ff, #1c1630);
--glow-rest: light-dark(rgba(124, 92, 255, 0), rgba(124, 92, 255, 0.2));
--glow-peak: light-dark(rgba(124, 92, 255, 0.7), #7c5cff);
--glow-peak-soft: light-dark(rgba(124, 92, 255, 0.3), rgba(124, 92, 255, 0.7));
box-shadow: 0 0 4px var(--glow-rest);
}
.stage.is-demo .badge {
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%, 15%, 85%, 100% {
box-shadow: 0 0 4px var(--glow-rest);
}
35%, 65% {
box-shadow: 0 0 16px var(--glow-peak), 0 0 40px var(--glow-peak-soft);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .badge {
animation: none;
}
}import type { CSSProperties } from "react";
import styles from "./LightDarkGlow.module.scss";
export type LightDarkGlowProps = {
/** 숨쉬기 한 번 (기본 2000ms) */
duration?: number;
color?: string;
};
export default function LightDarkGlow({ duration = 2000, color = "#7c5cff" }: LightDarkGlowProps) {
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div className={styles.row} style={vars}>
<div className={`${styles.pane} ${styles.light}`}>
<span className={styles.badge} />
</div>
<div className={`${styles.pane} ${styles.dark}`}>
<span className={styles.badge} />
</div>
</div>
);
}08 · light-dark() 대응 글로우 — 측정 누적면적 4.275% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
왜 이렇게 만들었나
.pane--light { color-scheme: light; }
.pane--dark { color-scheme: dark; }
.badge {
--glow: light-dark(rgba(124, 92, 255, .3), #7c5cff);
box-shadow: 0 0 4px var(--glow);
animation: breathe 2s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { box-shadow: 0 0 4px var(--glow); }
50% { box-shadow: 0 0 16px var(--glow); }
}
09스크롤에 따른 글로우 강도
화면에서 벌어지는 일
요소가 뷰포트 중앙에 가까워질수록 IntersectionObserver 가 교차 비율을 --intensity 에 그대로 넣어 글로우가 짙어진다.

<main class="stage is-demo">
<div class="row" aria-hidden="true">
<span class="track"><span class="dot"></span></span>
<div class="card">
<span class="bar bar--lg"></span>
<span class="bar bar--sm"></span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-loop; // 2s — 오르내림 한 번
$easing: ease-in-out;
$color: #3ec9ff; // 네온 블루
@include stage($stage-ink);
.row { display: flex; align-items: center; gap: $sp-5; }
$track-h: 96px;
.track {
position: relative;
width: 3px; height: $track-h;
border-radius: $r-xs;
background: rgba($stage-paper, .15);
}
.dot {
position: absolute; top: 0; left: 50%;
width: $sp-2; height: $sp-2; margin-left: -$sp-1;
border-radius: 50%;
background: $color;
box-shadow: 0 0 $sp-2 $color;
}
// @property 로 등록해야 숫자로 보간된다 — box-shadow·opacity 계산에 그대로 쓴다
@property --intensity { syntax: '<number>'; inherits: false; initial-value: 0; }
.card {
box-sizing: border-box;
width: clamp(150px, 44vmin, 200px);
padding: $sp-5 $sp-4;
border-radius: $r-card;
background: rgba($stage-paper, .04);
border: 1px solid rgba($stage-paper, .1);
display: flex; flex-direction: column; gap: $sp-3;
opacity: calc(.45 + var(--intensity) * .55);
box-shadow: 0 0 calc(var(--intensity) * #{$sp-3}) $color,
0 0 calc(var(--intensity) * #{$sp-10}) rgba($color, .55);
}
.bar { display: block; height: $sp-2; border-radius: $r-xs; background: rgba($stage-paper, .18); }
.bar--lg { width: 75%; }
.bar--sm { width: 48%; }
// translateY(점) · --intensity(카드) 만 움직인다
.stage.is-demo .dot {
animation: move $duration $easing infinite;
}
.stage.is-demo .card:not(.is-live) {
animation: rise $duration $easing infinite;
}
@keyframes move {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(#{$track-h - 8px}); }
}
@keyframes rise {
0%, 100% { --intensity: 0; }
50% { --intensity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot,
.stage.is-demo .card:not(.is-live) { animation: none; }
.card { --intensity: 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);
}
.row {
display: flex;
align-items: center;
gap: 20px;
}
.track {
position: relative;
width: 3px;
height: 96px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.15);
}
.dot {
position: absolute;
top: 0;
left: 50%;
width: 8px;
height: 8px;
margin-left: -4px;
border-radius: 50%;
background: #3ec9ff;
box-shadow: 0 0 8px #3ec9ff;
}
@property --intensity {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
.card {
box-sizing: border-box;
width: clamp(150px, 44vmin, 200px);
padding: 20px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 12px;
opacity: calc(0.45 + var(--intensity) * 0.55);
box-shadow: 0 0 calc(var(--intensity) * 12px) #3ec9ff, 0 0 calc(var(--intensity) * 40px) rgba(62, 201, 255, 0.55);
}
.bar {
display: block;
height: 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.18);
}
.bar--lg {
width: 75%;
}
.bar--sm {
width: 48%;
}
.stage.is-demo .dot {
animation: move 2s ease-in-out infinite;
}
.stage.is-demo .card:not(.is-live) {
animation: rise 2s ease-in-out infinite;
}
@keyframes move {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(88px);
}
}
@keyframes rise {
0%, 100% {
--intensity: 0;
}
50% {
--intensity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .dot,
.stage.is-demo .card:not(.is-live) {
animation: none;
}
.card {
--intensity: 1;
}
}// 실제 스크롤에서는 IntersectionObserver 가 뷰포트 교차 비율로 --intensity 를 정한다(8줄).
// 이 아이프레임은 너무 작아 실스크롤이 안 나므로, 데모 루프(.is-demo)가 같은 변수를 대신 돌린다.
(function () {
var card = document.querySelector('.card');
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (en) {
card.style.setProperty('--intensity', en.intersectionRatio.toFixed(2));
card.classList.add('is-live');
});
}, { threshold: [0, .25, .5, .75, 1] });
io.observe(card);
})();import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollIntensityGlow.module.scss";
export type ScrollIntensityGlowProps = {
/** intensity 값이 부드러워지는 시간 (기본 300ms) */
duration?: number;
color?: string;
};
export default function ScrollIntensityGlow({ duration = 300, color = "#3ec9ff" }: ScrollIntensityGlowProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
// 뷰포트 교차 비율(0~1)을 그대로 --intensity 로 쓴다 — 실제 스크롤에서만 값이 바뀐다
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((en) => el.style.setProperty("--intensity", en.intersectionRatio.toFixed(2)));
},
{ threshold: [0, .25, .5, .75, 1] }
);
io.observe(el);
return () => io.disconnect();
}, []);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--color": color } as CSSProperties;
return (
<div ref={cardRef} className={styles.card} style={vars}>
<span className={`${styles.bar} ${styles.barLg}`} />
<span className={`${styles.bar} ${styles.barSm}`} />
</div>
);
}09 · 스크롤에 따른 글로우 강도 — 측정 누적면적 1.083% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
구현의 핵심
실제 스크롤이 없는 자리에서는 같은 변수를 @keyframes 가 대신 돌려 보여준다.
@property --intensity { syntax: '<number>'; inherits: false; initial-value: 0; }
.card {
box-shadow: 0 0 calc(var(--intensity) * 12px) #3ec9ff,
0 0 calc(var(--intensity) * 40px) rgba(62, 201, 255, .55);
opacity: calc(.45 + var(--intensity) * .55);
transition: box-shadow .3s linear, opacity .3s linear;
}
어디서 깨지는가 — light-dark() 는 색에만 쓴다
무엇이 문제인가
네온사인 효과 만들기에서 처음 걸린 곳은 08번이었다 — box-shadow 의 흐림 반경까지 light-dark(4px, 8px) 로 써서 배경마다 다르게 하려 했다. 결과는 렌더 자체가 안 됐다 — 실측으로 강도 0.0, 24프레임 중 움직인 프레임 0이 나왔다. light-dark() 는 색(<color>) 값에만 유효하고, 길이에 쓰면 그 box-shadow 선언 전체가 무효 처리돼 조용히 사라진다.
어떻게 피하나
번짐이 겹쳐 색이 탁해지는 이 지점은, 화면 한가운데 커다란 빛덩어리를 두는 AI 랜딩페이지 오브 키트에서 가장 먼저 드러납니다.
색만 light-dark() 로 넘기고 반경은 그냥 숫자로 두면 된다. zip 압축을 풀 때 묻는 비밀번호는 azk2eev6 이고, 받은 파일 그대로 입력하면 열린다. 덧붙이면 box-shadow 애니메이션은 transform·opacity 처럼 GPU 합성이 아니라 매 프레임 다시 칠하는 페인트 비용이라, 레이어가 3겹을 넘어가면 저사양 기기에서 티가 난다 — 이 글의 데모는 전부 2겹 이내로 맞췄다.
접근성
reduced-motion 에서 남는 것
9개 전부 prefers-reduced-motion: reduce 에서 animation: none 으로 떨어진다. 움직임만 빠질 뿐 글로우 자체(색·그림자)는 남겨서, 깜빡임·펄스·순환이 불편한 사람도 무엇이 강조 대상인지는 그대로 알 수 있다.
그 밖에 챙길 것
04번 스포트라이트와 09번 스크롤 글로우는 @property 초기값으로 고정돼 갑자기 튀지 않는다. 자세한 배경은 MDN 의 reduced-motion 안내 를 참고할 만하다.
FAQ
CSS 만으로 네온사인 효과를 만들 수 있나요?
네, 이 9개 중 7개는 순수 CSS다. 04번(카드 스포트라이트)과 09번(스크롤 강도)만 실제 좌표·스크롤 값을 읽으려고 8줄 안팎의 짧은 JS 를 곁들였고, 그마저도 JS 없이는 자동 루프로 보이게 만들어 뒀다. 더 많은 CSS 전용 효과는 사이트 검색 에서 "glow" 로 찾아볼 수 있다.
왜 어떤 글로우는 버벅이나요?
box-shadow·filter 로 만든 글로우는 매 프레임 다시 칠해야 해서, 흐림 반경이 크고 레이어가 많을수록 무거워진다. transform·opacity 만 쓰는 애니메이션보다 비용이 크다는 걸 알고, 레이어 수를 2개 이내로 줄이는 편이 안전하다.
@property 는 어느 브라우저에서 되나요?
Chrome·Edge 는 오래전부터, Safari 는 16.4부터 지원한다. MDN 의 @property 문서 에 지원 표가 있고, 미지원 브라우저에서는 이 글의 04·07·09번 데모가 애니메이션 없이 고정된 글로우로만 보인다 — 레이아웃이 깨지지는 않는다. 사이트를 처음 본다면 소개 페이지 에서 이 블로그가 데모를 만드는 방식을 볼 수 있다. 빛이 카드 전체를 감싸며 반응해야 한다면 CSS 카드 호버 효과에 그 조합이 정리돼 있습니다.









