9 CSS Glow Effect Animations — Neon, Copy-Paste
A glowing button or headline looks lit from the inside like a neon sign, the trick gaming sites, streamers, and portfolios use to catch the eye.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Neon text flicker
- 02 Button glow on hover
- 03 Border glow pulse
- 04 Card spotlight glow
- 05 Icon glow ring
- 06 Underline glow
- 07 @property glow color cycle
- 08 light-dark() aware glow
- 09 Glow intensity tied to scroll
The order below follows where a glow shows up on a real page, not popularity. 01 and 02 light up something someone is reading or about to click — a headline, then a button. 03 and 04 sit on cards, one signaling a live state and one following a cursor. 05 and 06 mark a spot — a notification icon, then which tab is active. 07 and 08 solve two problems that plain CSS colors can't: a smooth rainbow cycle and a glow that adapts to light or dark on its own. 09 closes with a glow that reacts to how far a card has scrolled into view. To use the same light as a warning rather than a welcome, the shake animations fit that slot better, and splitting the glow across tiles is what the bento grid hover animations demonstrate.
01Neon text flicker
What it looks like
The letters look lit from the inside, brightening and dimming unevenly instead of holding one steady glow, the way a loosely wired neon tube actually behaves.

<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 · Neon text flicker — Measured area 6.513% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why it is done this way
Five layers of text-shadow stack up behind the color, and a flicker keyframe with thirteen uneven stops fades the whole stack's opacity in and out so the dips never fall into a predictable beat.
.neon {
text-shadow:
0 0 4px #ff2e63,
0 0 8px #ff2e63,
0 0 16px rgba(255, 46, 99, .8),
0 0 32px rgba(255, 46, 99, .6),
0 0 48px rgba(255, 46, 99, .35);
animation: flicker 2s linear 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; }
}
02Button glow on hover
Where a css glow effect button fits
A button carries a faint halo at rest, then the light spreads wider and brighter the instant a cursor hovers over it or keyboard focus lands on it.

<main class="stage is-demo">
<button class="btn" type="button" aria-label="Next">
<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 · Button glow on hover — Measured area 4.303% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Worth noting
Two stacked box-shadow layers grow in blur and opacity on :hover and :focus-visible together, so a mouse click and a Tab key press trigger the exact same light.
.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),
0 0 48px rgba(0, 229, 255, .3);
transform: scale(1.06);
}
03Border glow pulse
What you see
A ring of light traced around a card's border breathes wider and dimmer every couple of seconds, like a heartbeat telling a viewer this card is live right now rather than sitting idle.

<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 · Border glow pulse — Measured area 10.727% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
The key property
One box-shadow keyframe swings the blur radius and opacity between a quiet resting state and a bright peak, and the border's position never shifts while it does.
.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, .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),
0 0 48px rgba(57, 255, 136, .22);
border-color: rgba(57, 255, 136, .9);
}
}
04Card spotlight glow
Where it fits
A patch of colored light drifts in a slow loop across a card until a cursor lands on it, at which point the light stops looping and follows the pointer instead.

<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 · Card spotlight glow — Measured area 20.233% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to read in the code
A radial-gradient reads its center from two custom properties, --x and --y, and those only interpolate smoothly through @keyframes because @property registers them as a <percentage> first — an unregistered custom property would just jump from one value to the next with no motion in between.
@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%; }
}
05Icon glow ring
What moves
Two rings expand outward from a small icon and fade to nothing, one starting half a beat after the other, reading like a signal broadcasting outward the way a live or notification dot often does.
<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 · Icon glow ring — Measured area 6.007% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it is built
Both rings only animate scale and opacity on a staggered animation-delay, which makes this the lightest-weight glow in the whole set.
.ring {
position: absolute; inset: 0;
border-radius: 50%;
border: 2px solid #ffb020;
opacity: 0;
}
.ring--a { animation: ring 2s ease-out infinite; }
.ring--b { animation: ring 2s ease-out infinite; 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; }
}
06Underline glow
The moment it fires
A glowing underline slides beneath whichever tab is active, trailing a soft box-shadow behind it instead of snapping straight into place.

<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 · Underline glow — Measured area 3.618% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password
How it works
Both tabs sit at a fixed 96px width, so the underline's travel distance is one fixed number inside a translateX keyframe rather than something measured off the rendered page after the fact.
.tab { flex: 0 0 auto; width: 96px; }
.indicator {
position: absolute; left: 0; bottom: -8px;
width: 96px; height: 3px;
background: linear-gradient(90deg, transparent, #2df5c0, transparent);
box-shadow: 0 0 12px #2df5c0, 0 0 24px rgba(45, 245, 192, .6);
animation: slide 2s cubic-bezier(.2, .8, .2, 1) infinite;
}
@keyframes slide {
0%, 40% { transform: translateX(0); }
60%, 90% { transform: translateX(112px); }
100% { transform: translateX(0); }
}
07@property glow color cycle
What the user notices
A round badge's glow drifts through the whole color wheel on a two-second loop, never landing on the same shade twice in a cycle.

<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 glow color cycle — Measured area 4.603% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to wire it up
That smooth sweep only works because --hue is registered through @property as a <number>, letting hsl() interpolate the hue angle instead of jumping straight from one color to the next.
@property --hue { syntax: '<number>'; inherits: false; initial-value: 262; }
.badge {
width: 80px; height: 80px;
border-radius: 50%;
box-shadow: 0 0 16px #8b5cf6, 0 0 40px rgba(139, 92, 246, .5);
animation: cycle 2s linear infinite;
}
@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% / .55);
}
}
@keyframes cycle {
from { --hue: 0; }
to { --hue: 360; }
}
08light-dark() aware glow
Where this shows up
The same kind of badge glows faintly on a light background and strongly on a dark one, with no media query anywhere in the file and no JavaScript checking which theme is active.

<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() aware glow — Measured area 4.275% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
The core of it
Once color-scheme is set on the surrounding element, writing the glow's color through light-dark() lets that one line pick the right shade for itself on either background.
.pane--light { color-scheme: light; }
.pane--dark { color-scheme: dark; }
.badge {
--glow-rest: light-dark(rgba(124, 92, 255, 0), rgba(124, 92, 255, .2));
--glow-peak: light-dark(rgba(124, 92, 255, .7), #7c5cff);
box-shadow: 0 0 4px var(--glow-rest);
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); }
}
09Glow intensity tied to scroll
What happens on screen
A card's glow builds from barely-there to fully lit as it nears the center of the screen while scrolling, then fades back down once it drifts past.

<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 · Glow intensity tied to scroll — Measured area 1.083% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The idea behind it
An IntersectionObserver writes the intersection ratio straight into a registered --intensity custom property, and both box-shadow and opacity read that same value through calc(), so the glow and the fade always move together.
@property --intensity { syntax: '<number>'; inherits: false; initial-value: 0; }
.card {
opacity: calc(.45 + var(--intensity) * .55);
box-shadow: 0 0 calc(var(--intensity) * 12px) #3ec9ff,
0 0 calc(var(--intensity) * 40px) rgba(62, 201, 255, .55);
transition: box-shadow .3s linear, opacity .3s linear;
}
Where does light-dark() break?
What goes wrong
Item 08's badge, the one css neon glow effect here that has to survive both themes, only ever feeds light-dark() a <color> — the resting glow, the peak glow, the panel background. The first build of this demo also tried feeding it a length, swapping the blur radius between a smaller value on light panels and a wider one on dark panels. That version didn't render a dimmer glow on the wrong background — it rendered no glow at all, because light-dark() outside a <color> value doesn't fall back to either side quietly — it makes the entire box-shadow declaration invalid, and an invalid declaration gets dropped by the browser with no console warning.
How to avoid it
The point where overlapping bloom muddies the color shows up first in the AI orb landing kit, which parks one large glowing mass in the middle of the screen.
A 24-frame capture of that broken version measured zero frames of motion before the fix went in: keep light-dark() inside colors only, and leave every length — blur, spread, radius — as a plain number outside it. The full SCSS and React source for all nine glows, including the exact line that broke item 08 before the fix, opens with azk2eev6, typed exactly as it reads here. One more thing worth knowing before copying any of this: box-shadow repaints every frame instead of compositing on the GPU the way transform and opacity do, so stacking more than two or three layers — 01, 03, and 04 already sit at that edge — is the kind of choice that costs frame rate first on a low-end phone.
Variants worth trying
| Variant | Changed value | Feel |
|---|---|---|
| Softer spotlight | Item 04's gradient stop cut from 55% to 35% |
The light pools tighter around the cursor instead of washing across the whole card |
| Urgent flicker | Item 01's 2s loop shortened to 800ms |
Reads like a warning light rather than a lazily buzzing neon tube |
| Brand-locked cycle | Item 07's --hue keyframes limited to 200 through 260 |
Stays inside blue-violet the whole loop instead of crossing the full rainbow |
Accessibility
What reduced motion leaves
All nine drop their animation to none under prefers-reduced-motion: reduce, but the glow itself — the color, the shadow — stays on the page; only the motion goes quiet. 01 settles at a steady full-opacity glow instead of flickering, 03 holds one steady mid-brightness glow instead of pulsing, and 04, 07, and 09 freeze their @property values at a sensible default — the spotlight centers itself, the hue cycle parks on one violet, and the scroll glow reads as fully lit rather than snapping to zero. Browser coverage is the other limit worth knowing: @property reaches Chrome/Edge 85+ and Safari 16.4+, but Firefox only added it in version 128, and light-dark() needs Chrome/Edge 123+, Firefox 120+, or Safari 17.5+.
What else to check
Older engines skip 04, 07, 08, and 09's animated part and fall back to the static glow color already written into the CSS — nothing breaks, it just stops moving. More hover and click effects in the same copy-paste format live in 9 CSS button animations, and the rest of what this site has published sits in the CSS category hub.
FAQ
Do I need a css glow effect generator, or is this short enough to write by hand?
Every rule above is six to twenty lines of plain SCSS, short enough to type or paste without a generator tool. A generator is worth reaching for when you need dozens of one-off variations fast, but it usually outputs more shadow layers and vendor cruft than any of the nine here actually need.
Why does my text-shadow neon effect look flat instead of glowing?
A single text-shadow layer reads as a hard-edged outline, not a glow — glow comes from stacking several layers at increasing blur radius and decreasing opacity, the way item 01 stacks five. Skipping the outer, most-blurred layers is the most common reason a "neon" text effect ends up looking like plain colored text with a shadow.
What's the real difference between text-shadow and box-shadow for a glow?
text-shadow only follows the shape of glyphs, so it's the right choice for glowing letters like item 01. box-shadow follows an element's box (or its border-radius), which is why every glow on a button, card, or badge in this set — 02 through 09 — uses box-shadow instead. When the light has to wrap a whole card and answer the pointer, that combination is worked out in the CSS card hover effects.









