【コピペOK】CSS ネオン・グロー効果9選
ゲーム配信サイトやポートフォリオで、文字やボタンの周りが夜の看板のようにぼんやり光っているのを見たことはないでしょうか。あれは画像や動画ではなく、CSSのコードだけで光のにじみを描く演出で、css ネオンやグロー効果と呼ばれます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ネオン文字点滅
- 02 ボタングローホバー
- 03 枠グローパルス
- 04 カードスポットライトグロー
- 05 アイコングローリング
- 06 下線グロー
- 07 @propertyでグロー色相回転
- 08 light-dark()対応グロー
- 09 スクロールでグロー強度変化
9つは人気順ではなく、光る対象が画面のどこにあるかをたどる順で並べています。目立つ見出し文字(01)から始まり、押すボタン(02)・待機中のカード(03)・強調したいカード(04)・通知アイコン(05)・メニューの移動(06)を経て、注意を引き続けたいバッジ(07)・明暗テーマに合わせるバッジ(08)、最後はページに入ってきた瞬間に光るカード(09)で締めくくります。同じ光を歓迎ではなく警告として使いたいなら揺れるアニメーションのほうがその席に合い、光をタイル単位へ割って配る構成はベントグリッドのホバーが見せてくれます。
01ネオン文字点滅
見え方
文字の周りに何重にも重なったぼんやりした光がにじみ、古い蛍光灯の管のように明るさが不規則に上下します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
何が肝心か
これはtext-shadowを5層重ねてopacityだけを@keyframesで揺らす効果で、暗くなる瞬間の深さをわざと4段階に変えて機械的な点滅に見せません。
.neon {
color: #fff;
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%, 14%, 20%, 34%, 50%, 64%, 80%, 94%, 100% { opacity: 1; }
6% { opacity: .3; }
26% { opacity: .4; }
56% { opacity: .25; }
86% { opacity: .35; }
}
02ボタングローホバー
css ネオン ボタン をどこで使うか
ボタンの背後にうっすらとした光が普段は控えめに漂い、マウスを乗せるかTabキーでフォーカスすると光が一気に広がって明るくなります。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
押さえておく点
動いているのはbox-shadowの半径と不透明度、それにtransform: scaleだけで、ボタン自体の大きさや位置は変わりません。
.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);
}
03枠グローパルス
どの画面か
カードを囲む枠の光が、呼吸するように広がってはまた縮みます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
どう作るか
box-shadowのリングを@keyframesで0%→50%→100%と往復させ、光がいちばん強まる瞬間だけレイヤーを2重から3重に増やして密度そのものを濃く見せています。
.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);
}
}
04カードスポットライトグロー
何が動くか
カードの上を丸い光の点がゆっくり巡回し、四隅を順番に照らしていきます。この光はradial-gradientの中心座標を--x``--yという2つの変数で動かしていて、@propertyでパーセント値として登録してあるおかげで@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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの読み方
実際にカーソルがカードの上に来ると、8行ほどのJSが同じ--x``--yにマウス座標を代入し、巡回は止まってポインタ追従に切り替わります。
@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%);
}
@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アイコングローリング
どこで使うか
アイコンを囲む2つの円が、時間差をつけて交互に膨らみながら薄れていき、通知が波紋のように外へ広がります。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
コードの要点
動くのはtransform: scaleとopacityだけなので、この記事の9個の中でいちばん軽く動きます。
.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下線グロー
見えるもの
タブの下にある光る下線が、選んだタブの真下まで滑って移動します。2つのタブをどちらも96pxの固定幅にそろえてあるため、下線の移動距離は96px+間隔16pxの112pxと計算だけで決まり、レンダリング結果を測り直す必要がありません。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
動く仕組み
デモはこの往復を@keyframesで自動再生していますが、実際のタブ切り替えでは同じanimationをクリックで発火するtransitionに置き換えて使います。
.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);
}
@keyframes slide {
0%, 40% { transform: translateX(0); }
60%, 90% { transform: translateX(112px); } // 96px幅 + 16pxの間隔
100% { transform: translateX(0); }
}
07@propertyでグロー色相回転
どんな場面か
バッジを囲む光の色が、赤から紫まで虹のようになめらかに一周します。--hueという角度の変数を@propertyで数値として登録し、0から360まで@keyframesで回すとhsl(var(--hue) 90% 62%)の色がとぎれなく変化します。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
組み立て方
登録していない普通のカスタムプロパティではこの補間ができず、色が途中を飛ばしていきなり切り替わってしまいます。
@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);
}
@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()対応グロー
何が起きるか
同じバッジでも、明るい背景の上では光が控えめに、暗い背景の上では強く見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
なぜこう書くか
これはcolor-schemeを要素ごとに宣言したうえで光の色をlight-dark()で書いておくだけの仕組みで、メディアクエリを一行も書かずに背景に合わせて色が切り替わります。
.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);
--glow-peak-soft: light-dark(rgba(124, 92, 255, .3), rgba(124, 92, 255, .7));
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), 0 0 40px var(--glow-peak-soft);
}
}
09スクロールでグロー強度変化
画面で起きること
要素が画面の真ん中に近づくほど、カードの光がじわじわ濃くなっていきます。実際のページではIntersectionObserverが要素の交差率をそのまま--intensityという変数に入れ、box-shadowとopacityをcalc()でその数値に連動させています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
実装の核
--intensityも@propertyで数値として登録してあるので、値が途中を飛ばさず滑らかに濃淡が変わります。
@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;
}
どこで壊れるか — light-dark() は色の値にしか使えない
何が問題か
08番のバッジ — css ネオン 枠 を明暗テーマに合わせる項目 — を最初に作ったとき、box-shadowのにじみ幅までlight-dark(4px, 8px)のように書いて、背景ごとに太さも変えようとしました。結果はレンダリングそのものが崩れ、実測では強度が0.0まで落ち、フレームごとの変化がまったく検出されませんでした。light-dark()は<color>型の値にしか使えず、長さ(px)のような値に混ぜると、そのbox-shadowの宣言全体が無効になって静かに消えます——コンソールにエラーは出ません。直し方は単純で、色だけをlight-dark()に渡し、にじみ幅は普通の数値のまま残せば直ります。MDNのlight-dark()のドキュメントにもこの型の制約が書かれています。
どう避けるか
にじみが重なって色が濁る地点は、画面の中央へ大きな光のかたまりを置くAIランディングページのオーブキットで最初に表面化します。
ダークモード切り替え自体をアニメーションにする例はCSS ダークモード 切り替え アニメーション9選にまとめました。zipを解凍するときに聞かれるパスワードはazk2eev6で、そのまま入力すれば開きます。もう一つ覚えておきたいのは、box-shadowのアニメーションはtransformやopacityのようにGPUの合成では済まず、毎フレーム描き直すペイント処理だということです。にじみ幅が大きくレイヤーが3重を超えると非力な端末でコマ落ちしやすいため、この記事のデモは全部2重以内に抑えています。
アクセシビリティ
reduced-motion で残るもの
9つともprefers-reduced-motion: reduceではanimation: noneに切り替わり、光の点滅や巡回といった「動き」だけが止まります。色やbox-shadowそのものは残るので、強く光る/弱く光るの違いだけでどこが注目点かは変わらず伝わります。
そのほかに気をつけること
04番のスポットライトと09番のスクロール連動は@propertyの初期値に固定されるため、動きを止めた瞬間に光が急に飛んだり消えたりしません。背景にある考え方はMDNのprefers-reduced-motionの解説で確認できます。
FAQ
ネオン 文字 css のような光り方はCSSだけで作れますか?
作れます。9個のうち7個は純粋なCSSだけで完結していて、04番(カードスポットライト)と09番(スクロール連動)だけが実際の座標やスクロール位置を読み取るために8行前後の短いJSを添えています。それ以外のJSがなくても自動ループとして光って見えるようにしてあります。カードそのものを光らせる例はCSS カード ホバー アニメーション9選にも集めています。
なぜ一部のグローだけカクついて見えるのですか?
box-shadowやfilterで作る光は毎フレーム描き直す処理なので、にじみ幅が大きくレイヤーが多いほど重くなります。transformやopacityだけを動かすアニメーションより負荷が大きいことを踏まえ、レイヤー数を2つ以内に抑えておくと非力な端末でも安定しやすくなります。
@propertyはどのブラウザで使えますか?
ChromeとEdgeは以前から、SafariもSafari 16.4から対応していて、2024年にはFirefoxも追いつきBaseline 2024の「主要ブラウザで広く使える」機能になりました。MDNの@propertyのドキュメントに対応表があり、未対応の古いブラウザではこの記事の04・07・09番のデモがアニメーションなしの固定した光のまま表示されます。レイアウトが崩れる心配はありません。光がカード全体を包んで反応する必要があるなら、その組み合わせはCSSカードホバー効果に整理してあります。









